diff --git a/docs/configuration.md b/docs/configuration.md index 809b3dd068..05435a3b7f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,6 +6,10 @@ constraints and extensions `jsii` adds to the [package.json schema]. [package.json schema]: https://docs.npmjs.com/files/package.json +## jsii-config + +Use [jsii-config](https://github.com/aws/jsii/blob/master/jsii-config) to aid in configuring a new jsii module. + ## Additional Requirements & Extensions In order to be able to generate valid packages for all the supported target diff --git a/packages/jsii-config/.eslintrc.yaml b/packages/jsii-config/.eslintrc.yaml new file mode 100644 index 0000000000..8ca30cb87b --- /dev/null +++ b/packages/jsii-config/.eslintrc.yaml @@ -0,0 +1,2 @@ +--- +extends: ../../eslint-config.yaml diff --git a/packages/jsii-config/.gitignore b/packages/jsii-config/.gitignore new file mode 100644 index 0000000000..6ea4c5aebc --- /dev/null +++ b/packages/jsii-config/.gitignore @@ -0,0 +1,4 @@ +*.js +*.d.ts +node_modules/ +coverage/ diff --git a/packages/jsii-config/.npmignore b/packages/jsii-config/.npmignore new file mode 100644 index 0000000000..261c623a7c --- /dev/null +++ b/packages/jsii-config/.npmignore @@ -0,0 +1,13 @@ +* + +!**/*.js +!**/*.d.ts +!bin/jsii-config + +coverage + +# Don't include various configuration & state information +coverage +.eslintrc.* +tsconfig.json +*.tsbuildinfo diff --git a/packages/jsii-config/LICENSE b/packages/jsii-config/LICENSE new file mode 100644 index 0000000000..129acd53d9 --- /dev/null +++ b/packages/jsii-config/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/jsii-config/README.md b/packages/jsii-config/README.md new file mode 100644 index 0000000000..ac6c63bfd5 --- /dev/null +++ b/packages/jsii-config/README.md @@ -0,0 +1,27 @@ +# ![jsii-config](./demo.gif) + +jsii-config is a command line utility for configuring [jsii enabled modules](https://github.com/aws/jsii). It is useful to help convert an existing typescript module to a jsii module. It can also be used to revise existing jsii compiler configuration optiions, such as adding a new language target. + +[See the jsii documentation](https://github.com/aws/jsii/blob/master/docs/configuration.md) for more information on how the configuration options affect jsii's output. + +## Usage + +jsii-config requires an existing package.json with the following fields: + +- name +- version +- repository +- main +- author + +For details on the content of these fields, [see the jsii documentation](https://github.com/aws/jsii/blob/master/docs/configuration.md#additional-requirements--extensions). + +jsii-config can be called via npx. + +`npx jsii-config` + +When called without arguments, the resulting jsii-config will be written to the package.json in the current working directory. To specify a package.json file in another location, use the `--package-json` or `-p` option. + +`npx jsii-config -p ./some-dir/package.json` + +jsii-config can be run with the `--dry-run` or `-d` option to print the resulting config to stdout instead of writing to package.json. This can be useful for viewing the tool's output withou changing your package.json. diff --git a/packages/jsii-config/bin/jsii-config b/packages/jsii-config/bin/jsii-config new file mode 100755 index 0000000000..708f2fa7ba --- /dev/null +++ b/packages/jsii-config/bin/jsii-config @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('./jsii-config.js'); diff --git a/packages/jsii-config/bin/jsii-config.ts b/packages/jsii-config/bin/jsii-config.ts new file mode 100644 index 0000000000..451fdb217e --- /dev/null +++ b/packages/jsii-config/bin/jsii-config.ts @@ -0,0 +1,46 @@ +import * as yargs from 'yargs'; +import { promisify } from 'util'; +import { writeFile } from 'fs'; +import jsiiConfig from '../lib'; + +const writeFilePromise = promisify(writeFile); +/* + * Read package.json and prompt user for new or revised jsii config values. + */ +async function main() { + const argv = yargs + .command('$0 [args]', 'configure jsii compilation options in package.json') + .option('package-json', { + alias: 'p', + type: 'string', + description: 'location of module\'s package.json file', + default: './package.json' + }) + .option('dry-run', { + alias: 'd', + type: 'boolean', + description: 'print output to stdout, don\'t write to package.json', + default: false + }) + .help() + .argv; + + const packageJsonLocation = argv.packageJson as string; + const output = await jsiiConfig(packageJsonLocation); + + if (argv.dryRun) { + console.log(output); + } else { + await writeFilePromise(packageJsonLocation, JSON.stringify(output, null, 2)); + } +} + +main() + .then(() => { + console.log('Success!'); + process.exit(0); + }) + .catch(err => { + console.error(err.message); + process.exit(100); + }); diff --git a/packages/jsii-config/demo.gif b/packages/jsii-config/demo.gif new file mode 100644 index 0000000000..3cb1db6b2c Binary files /dev/null and b/packages/jsii-config/demo.gif differ diff --git a/packages/jsii-config/lib/index.ts b/packages/jsii-config/lib/index.ts new file mode 100644 index 0000000000..42eb14c13c --- /dev/null +++ b/packages/jsii-config/lib/index.ts @@ -0,0 +1,14 @@ +import { readFilePromise } from './util'; +import prompt from './prompt'; +import validatePackageJson from './validate'; + +export default async function jsiiConfig(packageJsonLocation: string) { + const manifest = await readFilePromise(packageJsonLocation); + const packageJson = validatePackageJson(JSON.parse(manifest.toString())); + + const answers = await prompt(packageJson); + return { + ...packageJson, + ...answers + }; +} diff --git a/packages/jsii-config/lib/prompt.ts b/packages/jsii-config/lib/prompt.ts new file mode 100644 index 0000000000..262d92d26e --- /dev/null +++ b/packages/jsii-config/lib/prompt.ts @@ -0,0 +1,45 @@ +import * as inquirer from 'inquirer'; +import { PackageJson } from 'jsii-spec'; +import getQuestions from './questions'; +import { BasePackageJson } from './schema'; +import { getNestedValue } from './util'; + +interface PromptAnswers extends PackageJson { + jsiiTargets: string[]; +} + +function getPassThroughValues(current: BasePackageJson): object { + const metadata = getNestedValue(['jsii', 'metadata'], current); + return { + ...metadata ? { metadata } : {} + }; +} + +/* + * Takes current config and prompts for new values + * + * Uses any values already present as defaults for the prompt + */ +export default async function getAnswers(current: BasePackageJson): Promise { + const answers = await inquirer.prompt(getQuestions(current)) as PromptAnswers; + const { jsiiTargets: _, ...config } = answers; + const confirmInput = await inquirer.prompt({ + type: 'confirm', + message: `Confirm Jsii Config\n${JSON.stringify(config, null, 2)}\nSelect no to revise`, + name: 'confirm' + }); + + const newConfig = { + ...config, + jsii: { + ...config.jsii, + ...getPassThroughValues(current) + } + }; + + if (confirmInput.confirm) { + return newConfig; + } + + return getAnswers(newConfig); +} diff --git a/packages/jsii-config/lib/questions.ts b/packages/jsii-config/lib/questions.ts new file mode 100644 index 0000000000..6086f5d27e --- /dev/null +++ b/packages/jsii-config/lib/questions.ts @@ -0,0 +1,49 @@ +import { QuestionCollection } from 'inquirer'; +import schema, { ConfigPromptsSchema, BasePackageJson } from './schema'; +import { getNestedValue, flattenKeys } from './util'; + +/* + * Get current value of field to be used as default + */ +function getCurrentValue(name: string, current: BasePackageJson): any { + if (current) { + return getNestedValue(name.split('.'), current); + } + return undefined; +} + +/* + * Recursively build array of questions based on config schema + * + * Pull defaults from current values in package.json or previous answers + */ +function flattenNestedQuestions(fields: ConfigPromptsSchema, current: BasePackageJson): QuestionCollection[] { + return Object.entries(fields).reduce((accum: QuestionCollection[], [name, question]: [string, any]) => { + if (question.type && question.message) { + const currentValue = getCurrentValue(name, current) || question.default; + return [...accum, { + name, + ...question, + ...currentValue ? { default: currentValue } : {} + }]; + } + + const flattened = flattenKeys(name, question); + return [...accum, ...flattenNestedQuestions(flattened, current)]; + }, []); +} + +function buildQuestions(schema: ConfigPromptsSchema, current: BasePackageJson): QuestionCollection[] { + const currentTargets = getNestedValue(['jsii', 'targets'], current) || {}; + const targetsPrompt: QuestionCollection = { + name: 'jsiiTargets', + message: 'Target Languages', + type: 'checkbox', + choices: Object.keys(schema.jsii.targets), + default: Object.keys(currentTargets) + }; + + return [targetsPrompt, ...flattenNestedQuestions(schema, current)]; +} + +export default (current: BasePackageJson) => buildQuestions(schema, current); diff --git a/packages/jsii-config/lib/schema.ts b/packages/jsii-config/lib/schema.ts new file mode 100644 index 0000000000..824b9b539b --- /dev/null +++ b/packages/jsii-config/lib/schema.ts @@ -0,0 +1,202 @@ +import { Question, Separator } from 'inquirer'; +import { Config, PackageJson, Stability } from 'jsii-spec'; + +/* + * Structure of package.json accepted by jsii-config + * + * Exits with error message if input is missing required fields. + */ +export interface BasePackageJson extends Omit { + jsii?: Config; + types?: string; + stability?: Stability; +} + +/* + * Type of fields used in schema to prompt users. + */ +type Field = Omit; + +interface NestedField { + [key: string]: SchemaField; +} + +type SchemaField = Field | NestedField; + +/* + * ConfigSchema + * + * Requires 'targets' key with available Jsii language targets. + */ +export interface ConfigPromptsSchema { + jsii: { + targets: SchemaField; + [key: string]: SchemaField; + }; + [key: string]: SchemaField; +} + +/* + * Builds a function to ask for target specific values when enabled + */ +const targetEnabled = (target: string) => (answers: any) => { + return Boolean(answers.jsiiTargets.includes(target)); +}; + +/* + * Validates that user input has length + */ +function hasLength(val: string): true | string { + const isValid = Boolean(val) && val.trim().length > 0; + if (isValid) { + return true; + } + return 'Please enter a value'; +} + +/* + * filter out values from answers that are allowed to be undefined + */ +function filterEmpty(val: string): string | void { + if (typeof val === 'string' && val.length === 0) { + return undefined; + } + return val; +} + +/* + * Schema that questions are built from. + * + * Name values for questions are built from parent keys. + */ +const schema: ConfigPromptsSchema = { + stability: { + type: 'list', + message: 'Jsii Stability - stability of compiled module apis', + default: Stability.Experimental, + choices: Object.values(Stability) + }, + types: { + type: 'input', + message: 'Jsii Type Definitions - compiled typescript definitions file for module (e.g. "index.d.ts")', + validate: hasLength + }, + jsii: { + outdir: { + type: 'input', + message: 'Output Directory - Location for typescript compiler output (e.g. "dist")', + default: 'dist', + validate: hasLength + }, + versionFormat: { + type: 'list', + message: 'Version Format - determines the format of the jsii toolchain version string that is included in the.jsii assembly file\'s jsiiVersion attribute', + default: 'full', + choices: [ + 'full', + new Separator('version number including a commit hash (e.g. "0.14.3 will be used"'), + new Separator(), + 'short', + new Separator('only the version number of jsii will be used (e.g. "0.14.3"') + ] + }, + targets: { + java: { + package: { + type: 'input', + message: 'Java Package - root java package name under which the types will be declared (e.g. "software.amazon.module.core")', + when: targetEnabled('java'), + validate: hasLength + }, + maven: { + groupId: { + type: 'input', + message: 'Maven GroupID - package group id (e.g. "software.amazon.module")', + when: targetEnabled('java'), + validate: hasLength + }, + artifactId: { + type: 'input', + message: 'Maven ArtifactID - package artifact id (e.g. "core")', + when: targetEnabled('java'), + validate: hasLength + }, + versionSuffix: { + type: 'input', + message: 'Maven Version Suffix - optional suffix appended to the end of the maven package\'s version field (e.g. ".DEVPREVIEW")', + when: targetEnabled('java'), + filter: filterEmpty + } + } + }, + python: { + distName: { + type: 'input', + message: 'Python Distname - PyPI distribution name for the package (e.g. "module-name.core")', + when: targetEnabled('python'), + validate: hasLength + }, + module: { + type: 'input', + message: 'Python Module - name of the generated Python module (e.g. "module_name.core")', + when: targetEnabled('python'), + validate: hasLength + } + }, + dotnet: { + namespace: { + type: 'input', + message: '.NET Namespace - root namespace under which types will be declared (e.g. "Amazon.Module")', + when: targetEnabled('dotnet'), + validate: hasLength + }, + packageId: { + type: 'input', + message: '.NET Package Id - identifier of the package in the NuGet registry (e.g. "Amazon.Module")', + when: targetEnabled('dotnet'), + validate: hasLength + }, + iconUrl: { + type: 'input', + message: '.NET Icon Url - optional url of the icon to be shown in the NuGet gallery (e.g. "https://raw.githubusercontent.com/module-icon.png")', + when: targetEnabled('dotnet'), + filter: filterEmpty + }, + versionSuffix: { + type: 'input', + default: '', + message: '.NET Version Suffix - optional suffix that will be appended at the end of the NuGet package\'s version field, must begin with a "-" (e.g. "-devpreview")', + when: targetEnabled('dotnet'), + validate: (val: string): true | string => { + const hasLengthResult = hasLength(val); + if (typeof hasLengthResult === 'string') { + return hasLengthResult; + } else if (!val.startsWith('-')) { + return 'versionSuffix must begin with "-"'; + } + + return true; + }, + filter: filterEmpty + }, + signAssembly: { + type: 'confirm', + default: false, + message: '.NET Sign Assembly - whether the assembly should be strong-name signed. Defaults to false when not specified', + when: targetEnabled('dotnet') + }, + assemblyOriginatorKeyFile: { + type: 'input', + default: '', + message: '.NET Assembly Originator Key File - path to the strong-name signing key to be used (e.g. "../../key.snk")', + when: (answers: any ) => { + return targetEnabled('dotnet')(answers) && Boolean(answers.jsii.targets.dotnet.signAssembly); + }, + validate: hasLength + } + } + } + } +}; + +export default schema; diff --git a/packages/jsii-config/lib/util.ts b/packages/jsii-config/lib/util.ts new file mode 100644 index 0000000000..77ef770170 --- /dev/null +++ b/packages/jsii-config/lib/util.ts @@ -0,0 +1,39 @@ +import { readFile } from 'fs'; + +/* + * Look for existing nested values in config, return undefined if not found + */ +export function getNestedValue(keys: string[], current: object): any { + try { + return keys.reduce((val: any, key: string) => val[key], current); + } catch (_err) { + return undefined; + } +} + +/* + * recursively flatten nested object + */ +export function flattenKeys(parent: string, fields: any) { + return Object.entries(fields).reduce((accum: any, [key, vals]: [string, any]) => ({ + ...accum, + [`${parent}.${key}`]: vals + }), {}); +} + +/** + * utility for for fs.readFile as a promise + * + * TODO: use util.promisify without breaking test stub + */ +export async function readFilePromise(path: string): Promise { + const result: Buffer = await new Promise((resolve, reject) => { + readFile(path, (err, data) => { + if (err) { + return reject(err); + } + return resolve(data); + }); + }); + return result; +} diff --git a/packages/jsii-config/lib/validate.ts b/packages/jsii-config/lib/validate.ts new file mode 100644 index 0000000000..cbd2a62da4 --- /dev/null +++ b/packages/jsii-config/lib/validate.ts @@ -0,0 +1,27 @@ +import { BasePackageJson } from './schema'; + +/* + * Top level keys required for jsii that aren't controlled by jsii-config + */ +const requiredNpmKeys: Array = [ + 'name', + 'version', + 'repository', + 'main', + 'author', +]; + +export default function validatePackageJson(packageJson: any): BasePackageJson { + const missingKeys = requiredNpmKeys.filter((key: string): boolean => !packageJson[key]); + + if (missingKeys.length > 0) { + throw new Error( + // Not the prettiest way to control indentation on multiline strings + `package.json is missing required fields:${'\n' + }${missingKeys.map(k => `- ${k}\n`).join('')}${'' + }run "npm init" or configure manually and retry jsii-config` + ); + } + + return packageJson as BasePackageJson; +} diff --git a/packages/jsii-config/package.json b/packages/jsii-config/package.json new file mode 100644 index 0000000000..9b15686147 --- /dev/null +++ b/packages/jsii-config/package.json @@ -0,0 +1,62 @@ +{ + "name": "jsii-config", + "version": "0.20.6", + "description": "CLI tool for configuring jsii module configuration in package.json", + "main": "lib/index.js", + "repository": "https://github.com/aws/jsii", + "author": "Amazon Web Services", + "license": "Apache-2.0", + "scripts": { + "build": "tsc --build && npm run lint", + "watch": "tsc --build -w", + "lint": "eslint . --ext .js,.ts --ignore-path=.gitignore --ignore-pattern=test/negatives/*", + "test": "jest", + "package": "package-js", + "exec": "npm run build && node bin/jsii-config.js" + }, + "bin": { + "jsii-config": "bin/jsii-config" + }, + "devDependencies": { + "@types/inquirer": "^6.5.0", + "@types/jest": "^24.0.23", + "@types/jest-expect-message": "^1.0.1", + "@types/yargs": "^13.0.3", + "@typescript-eslint/eslint-plugin": "^2.6.1", + "@typescript-eslint/parser": "^2.6.1", + "eslint": "^6.6.0", + "jest": "^24.9.0", + "jest-expect-message": "^1.0.2", + "jsii-spec": "0.20.6", + "typescript": "~3.6.4" + }, + "dependencies": { + "inquirer": "^7.0.0", + "yargs": "^14.2.0" + }, + "jest": { + "collectCoverage": true, + "collectCoverageFrom": [ + "**/bin/**/*.js", + "**/lib/**/*.js" + ], + "coverageReporters": [ + "lcov", + "text" + ], + "coverageThreshold": { + "global": { + "branches": 60, + "statements": 60 + } + }, + "errorOnDeprecated": true, + "setupFilesAfterEnv": [ + "jest-expect-message" + ], + "testEnvironment": "node", + "testMatch": [ + "**/?(*.)+(spec|test).js" + ] + } +} diff --git a/packages/jsii-config/test/index.test.ts b/packages/jsii-config/test/index.test.ts new file mode 100644 index 0000000000..9d968677f6 --- /dev/null +++ b/packages/jsii-config/test/index.test.ts @@ -0,0 +1,413 @@ +import * as fs from 'fs'; +import * as inquirer from 'inquirer'; +import jsiiConfig from '../lib'; +import { packageJsonObject, findQuestions, findQuestion } from './util'; + +describe('jsii-config', () => { + const promptMock = jest.fn(); + const readJsonMock = jest.fn(); + + beforeEach(() => { + Object.defineProperty(fs, 'readFile', { value: readJsonMock }); + Object.defineProperty(inquirer, 'prompt', { value: promptMock }); + }); + + afterEach(() => { + promptMock.mockClear(); + readJsonMock.mockClear(); + }); + + afterAll(() => { + promptMock.mockRestore(); + readJsonMock.mockRestore(); + }); + + describe('errors', () => { + it('throws when no readFile fails', async () => { + const message = 'Err Message'; + readJsonMock.mockImplementation((_path, cb) => { + cb(new Error(message)); + }); + await expect(jsiiConfig('unknown.json')).rejects.toThrow(message); + }); + + it('throws when package.json is invalid', async () => { + readJsonMock.mockImplementation((_path, cb) => { + cb(null, Buffer.from('INVALID JSON STRING')); + }); + + await expect(jsiiConfig('package.json')).rejects.toThrow('Unexpected token I in JSON at position 0'); + }); + }); + + describe('missing top level package fields', () => { + const mockMissingField = (field: string) => readJsonMock.mockImplementation((_path, cb) => { + cb(null, Buffer.from(JSON.stringify({ + ...packageJsonObject, + [field]: undefined + }))); + }); + + const requiredNpmFields = [ + 'name', + 'version', + 'repository', + 'main', + 'author' + ]; + + requiredNpmFields.forEach(field => { + it(`warns user on missing ${field} in package.json and exits`, async () => { + mockMissingField(field); + await expect(jsiiConfig('./package.json')).rejects.toThrow( + `package.json is missing required fields:${'\n' + }- ${field}${'\n' + }run "npm init" or configure manually and retry jsii-config` + ); + }); + }); + + }); + + describe('no existing jsii configuration', () => { + const configAnswers = { + stability: 'experimental', + types: 'index.d.ts', + jsii: { + outdir: 'dist', + versionFormat: 'short', + } + }; + + beforeEach(() => { + promptMock.mockResolvedValueOnce({ + jsiiTargets: [], + ...configAnswers + }).mockResolvedValueOnce({ + confirm: true + }); + + readJsonMock.mockImplementation((_path, cb) => { + cb(null, Buffer.from(JSON.stringify(packageJsonObject))); + }); + }); + + it('prompts user for top level jsii config and language targets', async () => { + await jsiiConfig('./package.json'); + const questions = promptMock.mock.calls[0][0]; + const [ + stability, + types, + outdir, + versionFormat, + targets + ] = findQuestions([ + 'stability', + 'types', + 'jsii.outdir', + 'jsii.versionFormat', + 'jsiiTargets' + ], questions); + expect(stability).toHaveProperty('type', 'list'); + expect(stability).toHaveProperty('choices', ['deprecated', 'experimental', 'stable', 'external']); + expect(stability).toHaveProperty('default', 'experimental'); + + expect(types).toHaveProperty('type', 'input'); + expect(types).not.toHaveProperty('default'); + + expect(outdir).toHaveProperty('type', 'input'); + expect(outdir).toHaveProperty('default', 'dist'); + + expect(versionFormat).toHaveProperty('type', 'list'); + expect(versionFormat.choices).toContain('full'); + expect(versionFormat.choices).toContain('short'); + + expect(targets).toHaveProperty('type', 'checkbox'); + expect(targets).toHaveProperty('choices', ['java', 'python', 'dotnet']); + }); + + it('prompts for java specific values when only target enabled', async () => { + await jsiiConfig('./package.json'); + const enabled = { jsiiTargets: ['java'] }; + const disabled = { jsiiTargets: [] }; + const questions = promptMock.mock.calls[0][0]; + const subject = findQuestions([ + 'jsii.targets.java.package', + 'jsii.targets.java.maven.groupId', + 'jsii.targets.java.maven.artifactId', + 'jsii.targets.java.maven.versionSuffix' + ], questions); + + subject.forEach((question: any) => { + expect(question.when(enabled)).toBe(true); + }); + + subject.forEach((question: any) => { + expect(question.when(disabled)).toBe(false); + }); + }); + + it('prompts for python specific values only when target enabled', async () => { + await jsiiConfig('./package.json'); + const enabled = { jsiiTargets: ['python'] }; + const disabled = { jsiiTargets: [] }; + const questions = promptMock.mock.calls[0][0]; + const subject = findQuestions([ + 'jsii.targets.python.module', + 'jsii.targets.python.distName' + ], questions); + + subject.forEach((question: any) => { + expect(question.when(enabled)).toBe(true); + }); + + subject.forEach((question: any) => { + expect(question.when(disabled)).toBe(false); + }); + }); + + it('prompts for dotnet specific values only when target enabled', async () => { + await jsiiConfig('./package.json'); + const enabled = { jsiiTargets: ['dotnet'] }; + const disabled = { jsiiTargets: [] }; + const questions = promptMock.mock.calls[0][0]; + const subject = findQuestions([ + 'jsii.targets.dotnet.namespace', + 'jsii.targets.dotnet.packageId', + 'jsii.targets.dotnet.iconUrl', + 'jsii.targets.dotnet.versionSuffix', + 'jsii.targets.dotnet.signAssembly' + ], questions); + + subject.forEach((question: any) => { + expect(question.when(enabled)).toBe(true); + }); + + subject.forEach((question: any) => { + expect(question.when(disabled)).toBe(false); + }); + }); + + it('prompts for dotnet assembly originator file when target and signAssembly are enabled', async () => { + await jsiiConfig('./package.json'); + const questions = promptMock.mock.calls[0][0]; + const subject = findQuestion('jsii.targets.dotnet.assemblyOriginatorKeyFile', questions); + const enabled = { + jsiiTargets: ['dotnet'], + jsii: { + targets: { + dotnet: { + signAssembly: true + } + } + } + }; + + const disabled = { + jsiiTargets: ['dotnet'], + jsii: { + targets: { + dotnet: { + signAssembly: false + } + } + } + }; + + expect(subject.when(enabled)).toBe(true); + expect(subject.when(disabled)).toBe(false); + }); + + it('returns new config', async () => { + const subject = await jsiiConfig('./package.json'); + + expect(subject).toEqual({ + ...packageJsonObject, + ...configAnswers + }); + }); + + [ + 'types', + 'jsii.outdir', + 'jsii.targets.java.package', + 'jsii.targets.java.maven.groupId', + 'jsii.targets.java.maven.artifactId', + 'jsii.targets.python.distName', + 'jsii.targets.python.module', + 'jsii.targets.dotnet.namespace', + 'jsii.targets.dotnet.packageId', + 'jsii.targets.dotnet.assemblyOriginatorKeyFile' + ].forEach(field => { + it(`shows error message when empty ${field} is submitted`, async () => { + await jsiiConfig('./package.json'); + const questions = promptMock.mock.calls[0][0]; + const subject = findQuestion(field, questions); + + expect(subject.validate()).toEqual('Please enter a value'); + expect(subject.validate('')).toEqual('Please enter a value'); + }); + }); + + it('shows error message when dotnet version suffix is submitted empty or without a "-"', async () => { + await jsiiConfig('./package.json'); + const questions = promptMock.mock.calls[0][0]; + const subject = findQuestion('jsii.targets.dotnet.versionSuffix', questions); + + expect(subject.validate()).toEqual('Please enter a value'); + expect(subject.validate('')).toEqual('Please enter a value'); + expect(subject.validate('xxx')).toEqual('versionSuffix must begin with "-"'); + expect(subject.validate('-xxx')).toEqual(true); + }); + }); + + describe('existing configuration', () => { + const existingConfig = { + stability: 'experimental', + types: 'TYPES', + jsii: { + outdir: 'OUTDIR', + versionFormat: 'short', + targets: { + java: { + package: 'JAVA_PACKAGE', + maven: { + groupId: 'JAVA_MAVEN_GROUPID', + artifactId: 'JAVA_MAVEN_ARTIFACTID' + } + }, + dotnet: { + namespace: 'DOTNET_NAMESPACE', + packageId: 'DOTNET_PACKAGEID', + iconUrl: 'DOTNET_ICONURL' + }, + python: { + distName: 'PYTHON_DISTNAME', + module: 'PYTHON_MODULE' + } + }, + metadata: { + 'jsii:boolean': true, + 'jsii:number': 1337 + } + } + }; + + const configAnswers = { + stability: 'stable', + types: 'new_types.d.ts', + jsii: { + outdir: 'dist', + versionFormat: 'short' + } + }; + + beforeEach(() => { + promptMock + .mockResolvedValueOnce(configAnswers) + .mockResolvedValueOnce({ + confirm: true + }); + + readJsonMock.mockImplementation((_path, cb) => { + cb(null, Buffer.from(JSON.stringify({ + ...packageJsonObject, + ...existingConfig + }))); + }); + }); + + it('uses existing values as prompt defaults', async () => { + await jsiiConfig('./package.json'); + const defaultMap: { [key: string]: any } = { + jsiiTargets: ['java', 'dotnet', 'python'], + stability: 'experimental', + types: 'TYPES', + ['jsii.outdir']: 'OUTDIR', + ['jsii.versionFormat']: 'short', + ['jsii.targets.java.package']: 'JAVA_PACKAGE', + ['jsii.targets.java.maven.groupId']: 'JAVA_MAVEN_GROUPID', + ['jsii.targets.java.maven.artifactId']: 'JAVA_MAVEN_ARTIFACTID', + ['jsii.targets.python.distName']: 'PYTHON_DISTNAME', + ['jsii.targets.python.module']: 'PYTHON_MODULE', + ['jsii.targets.dotnet.namespace']: 'DOTNET_NAMESPACE', + ['jsii.targets.dotnet.packageId']: 'DOTNET_PACKAGEID', + ['jsii.targets.dotnet.iconUrl']: 'DOTNET_ICONURL' + }; + const questions = promptMock.mock.calls[0][0]; + + Object.entries(defaultMap).forEach((entry: [string, any]) => { + const [name, defaultVal] = entry; + const subject = findQuestion(name, questions); + + expect(subject).toHaveProperty('default', defaultVal); + }); + }); + + it('preserves existing jsii metadata fields', async () => { + const subject = await jsiiConfig('./package.json'); + expect(subject).toEqual({ + ...packageJsonObject, + ...configAnswers, + jsii: { + ...configAnswers.jsii, + metadata: { + 'jsii:boolean': true, + 'jsii:number': 1337 + } + } + }); + }); + }); + + describe('edit config', () => { + const answers = { + jsiiTargets: ['python'], + jsii: { + outdir: 'OUTDIR', + versionFormat: 'short', + targets: { + python: { + distName: 'PYTHON_DISTNAME', + module: 'PYTHON_MODULE' + } + } + } + }; + + beforeEach(() => { + promptMock + .mockResolvedValueOnce(answers) + .mockResolvedValueOnce({ + confirm: false + }) + .mockResolvedValueOnce(answers) + .mockResolvedValueOnce({ + confirm: true + }); + + readJsonMock.mockImplementation((_path, cb) => { + cb(null, Buffer.from(JSON.stringify(packageJsonObject))); + }); + }); + + it('prompts user again with previous answers if confirmation is declined', async () => { + await jsiiConfig('./package.json'); + const defaultMap: { [key: string]: any } = { + jsiiTargets: ['python'], + ['jsii.outdir']: 'OUTDIR', + ['jsii.versionFormat']: 'short', + ['jsii.targets.python.distName']: 'PYTHON_DISTNAME', + ['jsii.targets.python.module']: 'PYTHON_MODULE', + }; + const questions = promptMock.mock.calls[2][0]; + + Object.entries(defaultMap).forEach((entry: [string, any]) => { + const [name, defaultVal] = entry; + const subject = findQuestion(name, questions); + + expect(subject).toHaveProperty('default', defaultVal); + }); + }); + }); +}); diff --git a/packages/jsii-config/test/util.ts b/packages/jsii-config/test/util.ts new file mode 100644 index 0000000000..6990d61cc3 --- /dev/null +++ b/packages/jsii-config/test/util.ts @@ -0,0 +1,21 @@ +import { BasePackageJson } from '../lib/schema'; + +export const packageJsonObject: BasePackageJson = { + name: 'jsii-config-test', + version: '0.0.1', + repository: { + url: 'https://github.com/aws/jsii', + type: 'git', + directory: '/packages/jsii-config' + }, + main: 'bin/jsii-config', + author: { + name: 'Amazon Web Services', + url: 'https://aws.amazon.com', + organization: true + } +}; + +export const findQuestion = (key: string, questions: any[]) => questions.find((question: any) => question.name === key); + +export const findQuestions = (keys: string[], question: any[]) => keys.map(key => findQuestion(key, question)); diff --git a/packages/jsii-config/tsconfig.json b/packages/jsii-config/tsconfig.json new file mode 100644 index 0000000000..ac08039e45 --- /dev/null +++ b/packages/jsii-config/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2018", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ + "lib": ["es2018"], /* Specify library files to be included in the compilation: */ + "strict": true, /* Enable all strict type-checking options. */ + "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + "declaration": true, /* Generates corresponding '.d.ts' file. */ + "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file. */ + "noUnusedLocals": true, /* Report errors on unused locals. */ + "noUnusedParameters": true, /* Report errors on unused parameters. */ + "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + "resolveJsonModule": true, /* Include modules imported with .json extension. */ + "composite": true, /* Ensure TypeScript can determine where to find the outputs of the referenced project to compile project. */ + "incremental": true, /* Enable incremental compilation by reading/writing information from prior compilations to a file on disk. */ + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "test/negatives/*" + ] +} diff --git a/packages/jsii-spec/lib/configuration.ts b/packages/jsii-spec/lib/configuration.ts new file mode 100644 index 0000000000..ff5e61ba19 --- /dev/null +++ b/packages/jsii-spec/lib/configuration.ts @@ -0,0 +1,154 @@ +import { Stability } from './assembly'; + +/** + * Structure of jsii configuration in package.json + */ +export interface Config { + /** + * Output directory of typescript compiler + */ + outdir: string; + + /** + * Determines the format of the jsii toolchain version string that will be + * included in the .jsii assembly file's jsiiVersion attribute. + * + * full (the default) - a version number including a commit hash will be used + * example: 0.14.3 (build 1b1062d) + * + * short - only the version number of jsii will be used + * example: 0.14.3 + */ + versionFormat: 'full' | 'short'; + + /** + * Defines which target languages the module supports. + */ + targets: { + java?: { + /* + * generated maven package name + */ + package: string; + + /** + * groupId and artifactId for generated maven package. + */ + maven: { + groupId: string; + artifactId: string; + + /** + * optional version suffix for maven module version + */ + versionSuffix?: string; + }; + }; + python?: { + /** + * name of generated Python module, which will be used by users in import + * directives + */ + module: string; + + /** + * the PyPI distribution name for the package. + */ + distName: string; + }; + dotnet?: { + /** + * the root namespace under which types will be declared + */ + namespace: string; + + /** + * identifier of the package in the NuGet registry + */ + packageId: string; + + /** + * url of the icon to be shown in the NuGet Gallery + */ + iconUrl?: string; + + /** + * optional suffix that will be appended at the end of the NuGet package's + * version field + */ + versionSuffix?: string; + + /** + * whether the assembly should be strong-name signed + */ + signAssembly?: boolean; + + /** + * path to the strong-name signing key to be used + */ + assemblyOriginatorFile?: string; + }; + }; + + /** + * used to record additional metadata as key-value pairs that will be recorded + * as-is into the .jsii assembly file + */ + metadata?: { + [key: string]: any; + }; +} + +/** + * Structure of jsii module package.json + */ +export interface PackageJson { + /** + * module name for typescript module published to npmjs + */ + name: string; + + /** + * module's current semantic version number + */ + version: string; + + /** + * module's source code repository information + */ + repository: string | { + url: string; + type?: string; + directory?: string; + }; + + /** + * main module entrypoint file + */ + main: string; + + /** + * module's primary author information + */ + author: string | { + name: string; + email?: string; + url?: string; + organization?: boolean; + }; + + /** + * jsii compiler configuration + */ + jsii: Config; + + /** + * module's built typescript definitions file location + */ + types: string; + + /** + * module's api stability level + */ + stability?: Stability; +} diff --git a/packages/jsii-spec/lib/index.ts b/packages/jsii-spec/lib/index.ts index 8b7d1a1a12..27644b56f8 100644 --- a/packages/jsii-spec/lib/index.ts +++ b/packages/jsii-spec/lib/index.ts @@ -1,3 +1,4 @@ export * from './assembly'; export * from './name-tree'; export * from './validate-assembly'; +export * from './configuration'; diff --git a/yarn.lock b/yarn.lock index b18972dab0..9d5c11fd2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1178,6 +1178,14 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/inquirer@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-6.5.0.tgz#b83b0bf30b88b8be7246d40e51d32fe9d10e09be" + integrity sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw== + dependencies: + "@types/through" "*" + rxjs "^6.4.0" + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -1271,6 +1279,13 @@ "@types/minipass" "*" "@types/node" "*" +"@types/through@*": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.29.tgz#72943aac922e179339c651fa34a4428a4d722f93" + integrity sha512-9a7C5VHh+1BKblaYiq+7Tfc+EOmjMdZaD1MYtkQjSoxgB69tBjW98ry6SKsi4zEIWztLOMRuL87A3bdT/Fc/4w== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "13.1.0" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-13.1.0.tgz#c563aa192f39350a1d18da36c5a8da382bbd8228"