diff --git a/tools/@aws-cdk/spec2cdk/README.md b/tools/@aws-cdk/spec2cdk/README.md index deeb57fdfec12..b2aeac3be97cd 100644 --- a/tools/@aws-cdk/spec2cdk/README.md +++ b/tools/@aws-cdk/spec2cdk/README.md @@ -2,48 +2,70 @@ Generates AWS CDK L1s in TypeScript from `@aws-cdk/aws-service-spec`. -```console -Usage: +## Usage - spec2cdk OUTPUT-PATH [--option=value] +```ts +import { generateAll } from '@aws-cdk/spec2cdk'; +declare const outputDir: string; -Options: +// Generate all modules +await generateAll(outputPath, { outputPath }); - Note: Passing values to non-boolean options MUST use the = sign: --option=value - - --augmentations [string] [default: "%moduleName%/%serviceShortName%-augmentations.generated.ts"] - File and path pattern for generated augmentations files - --augmentations-support [boolean] [default: false] - Generates additional files required for augmentation files to compile. Use for testing only. - --clear-output [boolean] [default: false] - Completely delete the output path before generating new files - --debug [boolean] [default: false] - Show additional debug output - --metrics [string] [default: "%moduleName%/%serviceShortName%-canned-metrics.generated.ts"] - File and path pattern for generated canned metrics files - --pattern [string] [default: "%moduleName%/%serviceShortName%.generated.ts"] - File and path pattern for generated files - --service [string] [default: all services] - Generate files only for a specific service, e.g. aws-lambda - -Path patterns can use the following variables: +// Generate modules with specific instructions +await generate({ + 'aws-lambda': { services: ['AWS::Lambda'] }, + 'aws-s3': { services: ['AWS::S3'] }, +}, { outputPath }); +``` - %moduleName% The name of the module, e.g. aws-lambda - %serviceName% The full name of the service, e.g. aws-lambda - %serviceShortName% The short name of the service, e.g. lambda +Refer to code autocompletion for all options. - Note that %moduleName% and %serviceName% can be different if multiple services are generated into a single module. +### Use as @aws-cdk/cfn2ts replacement -``` +The package provides a binary that can be used as a drop-in replacement of the legacy `@aws-cdk/cfn2ts` package. +At a code level, import `@aws-cdk/spec2cdk/lib/cfn2ts` for a drop-in replacement. ## Temporary Schemas You can import additional, temporary CloudFormation Registry Schemas to test new functionality that is not yet published in `@aws-cdk/aws-service-spec`. To do this, drop the schema file into `temporary-schemas/us-east-1` and it will be imported on top of the default model. -## Use as @aws-cdk/cfn2ts replacement +## CLI -You can use the `cfn2ts` binary as a drop-in replacement for the existing `@aws-cdk/cfn2ts` command. +A CLI is available for testing and ad-hoc usage. +However its API is limited and you should use the programmatic interface for implementations. -At a code level, import `@aws-cdk/spec2cdk/lib/cfn2ts` for a drop-in replacement. +```console +Usage: + spec2cdk [--option=value] + +Arguments: + OUTPUT-PATH The directory the generated code will be written to + +Options: + --augmentations [string] [default: %moduleName%/%serviceShortName%-augmentations.generated.ts] + File and path pattern for generated augmentations files + --augmentations-support [boolean] + Generates additional files required for augmentation files to compile. Use for testing only + --clear-output [boolean] + Completely delete the output path before generating new files + --debug [boolean] + Show additional debug output + -h, --help [boolean] + Show this help + --metrics [string] [default: %moduleName%/%serviceShortName%-canned-metrics.generated.ts] + File and path pattern for generated canned metrics files + --pattern [string] [default: %moduleName%/%serviceShortName%.generated.ts] + File and path pattern for generated files + -s, --service [array] + Generate files only for a specific service, e.g. AWS::S3 + +Path patterns can use the following variables: + + %moduleName% The name of the module, e.g. aws-lambda + %serviceName% The full name of the service, e.g. aws-lambda + %serviceShortName% The short name of the service, e.g. lambda + +Note that %moduleName% and %serviceName% can be different if multiple services are generated into a single module. +``` diff --git a/tools/@aws-cdk/spec2cdk/lib/cli/args.ts b/tools/@aws-cdk/spec2cdk/lib/cli/args.ts deleted file mode 100644 index f6241a83e508d..0000000000000 --- a/tools/@aws-cdk/spec2cdk/lib/cli/args.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Simple way to parse arguments - * - * Given the command line: - * - * ``` - * command --arg=value --flag hello - * ^^^^^ - * NOTE: '--arg value' will not parse as an argument - * ``` - * - * And the invocation: - * - * ``` - * parseArgv(args, ['greeting']) - * ``` - * - * Returns: - * - * ``` - * args: { "greeting": "hello" } - * options: { "arg": "value", "flag": true } - * ``` - */ -export function parseArgv( - argv: string[], - namedArgs: string[], -): { - args: Record; - options: Record; - } { - return { - args: Object.fromEntries( - argv - .filter((a) => !a.startsWith('--')) - .map(function (arg, idx) { - return [namedArgs[idx] ?? idx, arg]; - }), - ), - options: Object.fromEntries( - argv.filter((a) => a.startsWith('--')).map((a) => [a.split('=')[0].substring(2), a.split('=', 2)[1] ?? true]), - ), - }; -} diff --git a/tools/@aws-cdk/spec2cdk/lib/cli/cli.ts b/tools/@aws-cdk/spec2cdk/lib/cli/cli.ts new file mode 100644 index 0000000000000..51a50d167f001 --- /dev/null +++ b/tools/@aws-cdk/spec2cdk/lib/cli/cli.ts @@ -0,0 +1,145 @@ +import * as path from 'node:path'; +import { parseArgs } from 'node:util'; +import { PositionalArg, showHelp } from './help'; +import { GenerateModuleMap, PatternKeys, generate, generateAll } from '../generate'; +import { log, parsePattern } from '../util'; + +const command = 'spec2cdk'; +const args: PositionalArg[] = [{ + name: 'output-path', + required: true, + description: 'The directory the generated code will be written to', +}]; +const config = { + 'help': { + short: 'h', + type: 'boolean', + description: 'Show this help', + }, + 'debug': { + type: 'boolean', + description: 'Show additional debug output', + }, + 'pattern': { + type: 'string', + default: '%moduleName%/%serviceShortName%.generated.ts', + description: 'File and path pattern for generated files', + }, + 'augmentations': { + type: 'string', + default: '%moduleName%/%serviceShortName%-augmentations.generated.ts', + description: 'File and path pattern for generated augmentations files', + }, + 'metrics': { + type: 'string', + default: '%moduleName%/%serviceShortName%-canned-metrics.generated.ts', + description: 'File and path pattern for generated canned metrics files ', + }, + 'service': { + short: 's', + type: 'string', + description: 'Generate files only for a specific service, e.g. AWS::S3', + multiple: true, + }, + 'clear-output': { + type: 'boolean', + default: false, + description: 'Completely delete the output path before generating new files', + }, + 'augmentations-support': { + type: 'boolean', + default: false, + description: 'Generates additional files required for augmentation files to compile. Use for testing only', + }, +} as const; + +const helpText = `Path patterns can use the following variables: + + %moduleName% The name of the module, e.g. aws-lambda + %serviceName% The full name of the service, e.g. aws-lambda + %serviceShortName% The short name of the service, e.g. lambda + +Note that %moduleName% and %serviceName% can be different if multiple services are generated into a single module.`; + +const help = () => showHelp(command, args, config, helpText); +export const shortHelp = () => showHelp(command, args); + +export async function main(argv: string[]) { + const { + positionals, + values: options, + } = parseArgs({ + args: argv, + allowPositionals: true, + options: config, + }); + + if (options.help) { + help(); + return; + } + + if (options.debug) { + process.env.DEBUG = '1'; + } + log.debug('CLI args', positionals, options); + + const outputDir = positionals[0]; + if (!outputDir) { + throw new EvalError('Please specify the output-path'); + } + + const pss: Record = { moduleName: true, serviceName: true, serviceShortName: true }; + + const outputPath = outputDir ?? path.join(__dirname, '..', 'services'); + const resourceFilePattern = parsePattern( + stringOr(options.pattern, path.join('%moduleName%', '%serviceShortName%.generated.ts')), + pss, + ); + + const augmentationsFilePattern = parsePattern( + stringOr(options.augmentations, path.join('%moduleName%', '%serviceShortName%-augmentations.generated.ts')), + pss, + ); + + const cannedMetricsFilePattern = parsePattern( + stringOr(options.metrics, path.join('%moduleName%', '%serviceShortName%-canned-metrics.generated.ts')), + pss, + ); + + const generatorOptions = { + outputPath, + filePatterns: { + resources: resourceFilePattern, + augmentations: augmentationsFilePattern, + cannedMetrics: cannedMetricsFilePattern, + }, + clearOutput: options['clear-output'], + augmentationsSupport: options['augmentations-support'], + debug: options.debug as boolean, + }; + + if (options.service?.length) { + const moduleMap: GenerateModuleMap = {}; + for (const service of options.service) { + if (!service.includes('::')) { + throw new EvalError(`Each service must be in the form ::, e.g. AWS::S3. Got: ${service}`); + } + moduleMap[service.toLocaleLowerCase().split('::').join('-')] = { services: [service] }; + } + await generate(moduleMap, generatorOptions); + return; + } + + await generateAll(generatorOptions); +} + +function stringOr(pat: unknown, def: string) { + if (!pat) { + return def; + } + if (typeof pat !== 'string') { + throw new Error(`Expected string, got: ${JSON.stringify(pat)}`); + } + return pat; +} diff --git a/tools/@aws-cdk/spec2cdk/lib/cli/help.ts b/tools/@aws-cdk/spec2cdk/lib/cli/help.ts new file mode 100644 index 0000000000000..fdbd310a0ecef --- /dev/null +++ b/tools/@aws-cdk/spec2cdk/lib/cli/help.ts @@ -0,0 +1,83 @@ +/* eslint-disable no-console */ + +export interface PositionalArg { + name: string; + description?: string; + required?: boolean +} + +export interface Option { + type: 'string' | 'boolean', + short?: string; + default?: string | boolean; + multiple?: boolean; + description?: string; +} + +const TAB = ' '.repeat(4); + +export function showHelp(command: string, args: PositionalArg[] = [], options: { + [longOption: string]: Option +} = {}, text?: string) { + console.log('Usage:'); + console.log(`${TAB}${command} ${renderArgsList(args)} [--option=value]`); + + const leftColSize = 6 + longest([ + ...args.map(a => a.name), + ...Object.entries(options).map(([name, def]) => renderOptionName(name, def.short)), + ]); + + if (args.length) { + console.log('\nArguments:'); + for (const arg of args) { + console.log(`${TAB}${arg.name.toLocaleUpperCase().padEnd(leftColSize)}\t${arg.description}`); + } + } + + if (Object.keys(options).length) { + console.log('\nOptions:'); + const ordered = Object.entries(options).sort(([a], [b]) => a.localeCompare(b)); + for (const [option, def] of ordered) { + console.log(`${TAB}${renderOptionName(option, def.short).padEnd(leftColSize)}\t${renderOptionText(def)}`); + } + } + console.log(); + + if (text) { + console.log(text + '\n'); + } +} + +function renderArgsList(args: PositionalArg[] = []) { + return args.map(arg => { + const brackets = arg.required ? ['<', '>'] : ['[', ']']; + return `${brackets[0]}${arg.name.toLocaleUpperCase()}${brackets[1]}`; + }).join(' '); +} + +function renderOptionName(option: string, short?: string): string { + if (short) { + return `-${short}, --${option}`; + } + + return `${' '.repeat(4)}--${option}`; +} + +function renderOptionText(def: Option): string { + const out = new Array; + + out.push(`[${def.multiple ? 'array' : def.type}]`); + + if (def.default) { + out.push(` [default: ${def.default}]`); + } + if (def.description) { + out.push(`\n${TAB.repeat(2)} ${def.description}`); + } + + return out.join(''); +} + +function longest(xs: string[]): number { + return xs.sort((a, b) => b.length - a.length).at(0)?.length ?? 0; +} diff --git a/tools/@aws-cdk/spec2cdk/lib/cli/index.ts b/tools/@aws-cdk/spec2cdk/lib/cli/index.ts index b9886aee98d0a..2c5e0fb970cf9 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cli/index.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cli/index.ts @@ -1,65 +1,13 @@ -import * as path from 'path'; -import { parseArgv } from './args'; -import { PatternKeys, generate, generateAll } from '../generate'; -import { log, parsePattern } from '../util'; - -async function main(argv: string[]) { - const { args, options } = parseArgv(argv, ['output']); - if (options.debug) { - process.env.DEBUG = '1'; - } - log.debug('CLI args', args, options); - - const pss: Record = { moduleName: true, serviceName: true, serviceShortName: true }; - - const outputPath = args.output ?? path.join(__dirname, '..', 'services'); - const resourceFilePattern = parsePattern( - stringOr(options.pattern, path.join('%moduleName%', '%serviceShortName%.generated.ts')), - pss, - ); - - const augmentationsFilePattern = parsePattern( - stringOr(options.augmentations, path.join('%moduleName%', '%serviceShortName%-augmentations.generated.ts')), - pss, - ); - - const cannedMetricsFilePattern = parsePattern( - stringOr(options.metrics, path.join('%moduleName%', '%serviceShortName%-canned-metrics.generated.ts')), - pss, - ); - - const generatorOptions = { - outputPath, - filePatterns: { - resources: resourceFilePattern, - augmentations: augmentationsFilePattern, - cannedMetrics: cannedMetricsFilePattern, - }, - clearOutput: !!options['clear-output'], - augmentationsSupport: !!options['augmentations-support'], - debug: options.debug as boolean, - }; - - if (options.service && typeof options.service === 'string') { - const moduleMap = { [options.service]: { services: [options.service] } }; - await generate(moduleMap, generatorOptions); - return; - } - - await generateAll(generatorOptions); -} +import { main, shortHelp } from './cli'; +import { log } from '../util'; main(process.argv.splice(2)).catch((e) => { - log.error(e); process.exitCode = 1; -}); -function stringOr(pat: unknown, def: string) { - if (!pat) { - return def; - } - if (typeof pat !== 'string') { - throw new Error(`Expected string, got: ${JSON.stringify(pat)}`); + if (e instanceof EvalError) { + log.error(`Error: ${e.message}\n`); + shortHelp(); + } else { + log.error(e); } - return pat; -} +}); \ No newline at end of file diff --git a/tools/@aws-cdk/spec2cdk/lib/generate.ts b/tools/@aws-cdk/spec2cdk/lib/generate.ts index d52d3b4f9e601..7b15e7925c456 100644 --- a/tools/@aws-cdk/spec2cdk/lib/generate.ts +++ b/tools/@aws-cdk/spec2cdk/lib/generate.ts @@ -225,7 +225,7 @@ async function generator( const result = { modules: moduleMap, - resources: Object.values(moduleMap).flat().map(pick('resources')).reduce(mergeObjects), + resources: Object.values(moduleMap).flat().map(pick('resources')).reduce(mergeObjects, {}), outputFiles: Object.values(moduleMap).flat().flatMap(pick('outputFiles')), }; diff --git a/tools/@aws-cdk/spec2cdk/package.json b/tools/@aws-cdk/spec2cdk/package.json index e0e1ae00bc0f9..094f0e15ebecc 100644 --- a/tools/@aws-cdk/spec2cdk/package.json +++ b/tools/@aws-cdk/spec2cdk/package.json @@ -45,7 +45,7 @@ "@aws-cdk/cdk-build-tools": "0.0.0", "@aws-cdk/pkglint": "0.0.0", "@types/jest": "^29.5.5", - "@types/node": "^16", + "@types/node": "^18", "jest": "^29.7.0" }, "keywords": [ diff --git a/tools/@aws-cdk/spec2cdk/test/cli.test.ts b/tools/@aws-cdk/spec2cdk/test/cli.test.ts new file mode 100644 index 0000000000000..0a2dc78d9946f --- /dev/null +++ b/tools/@aws-cdk/spec2cdk/test/cli.test.ts @@ -0,0 +1,33 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { main } from '../lib/cli/cli'; + +describe('cli', () => { + test('can generate specific services', async () => { + await withTemporaryDirectory(async ({ testDir }) => { + await main([testDir, '--service', 'AWS::S3', '--service', 'AWS::SNS']); + + expect(fs.existsSync(path.join(testDir, 'aws-s3', 's3.generated.ts'))).toBe(true); + expect(fs.existsSync(path.join(testDir, 'aws-sns', 'sns.generated.ts'))).toBe(true); + }); + }); +}); + +interface TemporaryDirectoryContext { + readonly testDir: string; +} + +async function withTemporaryDirectory(block: (context: TemporaryDirectoryContext) => Promise) { + const testDir = path.join(os.tmpdir(), 'spec2cdk-test'); + fs.mkdirSync(testDir, { recursive: true }); + + try { + await block({ testDir }); + } finally { + fs.rmSync(testDir, { + recursive: true, + force: true, + }); + } +} diff --git a/yarn.lock b/yarn.lock index 7f40a39dc8a31..cf2d68b5cfacb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5028,6 +5028,13 @@ resolved "https://registry.npmjs.org/@types/node/-/node-16.18.54.tgz#4a63bdcea5b714f546aa27406a1c60621236a132" integrity sha512-oTmGy68gxZZ21FhTJVVvZBYpQHEBZxHKTsGshobMqm9qWpbqdZsA5jvsuPZcHu0KwpmLrOHWPdEfg7XDpNT9UA== +"@types/node@^18": + version "18.18.9" + resolved "https://registry.npmjs.org/@types/node/-/node-18.18.9.tgz#5527ea1832db3bba8eb8023ce8497b7d3f299592" + integrity sha512-0f5klcuImLnG4Qreu9hPj/rEfFq6YRc5n2mAjSsH+ec/mJL+3voBH0+8T7o8RpFjH7ovc+TRsL/c7OYIQsPTfQ== + dependencies: + undici-types "~5.26.4" + "@types/normalize-package-data@^2.4.0": version "2.4.2" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.2.tgz#9b0e3e8533fe5024ad32d6637eb9589988b6fdca" @@ -14402,6 +14409,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + uniq@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff"