diff --git a/.gitignore b/.gitignore index 96a21c1ca19..8c6f0d0872c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,12 @@ spec-examples.zip coverage +# Analytics + +.asyncapi-analytics + # Glee assets/create-glee-app/templates/default/.glee assets/create-glee-app/templates/tutorial/.glee assets/create-glee-app/templates/default/docs -assets/create-glee-app/templates/tutorial/docs \ No newline at end of file +assets/create-glee-app/templates/tutorial/docs diff --git a/package-lock.json b/package-lock.json index 680a5a321f2..0bf436f42cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@asyncapi/protobuf-schema-parser": "^3.2.11", "@asyncapi/raml-dt-schema-parser": "^4.0.14", "@asyncapi/studio": "^0.20.0", + "@clack/prompts": "^0.7.0", "@oclif/core": "^1.26.2", "@oclif/errors": "^1.3.6", "@oclif/plugin-not-found": "^2.3.22", @@ -39,6 +40,7 @@ "node-fetch": "^2.0.0", "oclif": "^4.2.0", "open": "^8.4.0", + "picocolors": "^1.0.0", "reflect-metadata": "^0.1.13", "request": "^2.88.2", "serve-handler": "^6.1.3", @@ -3438,6 +3440,40 @@ "node": ">=6.9.0" } }, + "node_modules/@clack/core": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.4.tgz", + "integrity": "sha512-H4hxZDXgHtWTwV3RAVenqcC4VbJZNegbBjlPvzOzCouXtS2y3sDvlO3IsbrPNWuLWPPlYVYPghQdSF64683Ldw==", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz", + "integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==", + "bundleDependencies": [ + "is-unicode-supported" + ], + "dependencies": { + "@clack/core": "^0.3.3", + "is-unicode-supported": "*", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts/node_modules/is-unicode-supported": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -66669,6 +66705,11 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/package.json b/package.json index 15a637ba2d9..b0488acc73f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@asyncapi/protobuf-schema-parser": "^3.2.11", "@asyncapi/raml-dt-schema-parser": "^4.0.14", "@asyncapi/studio": "^0.20.0", + "@clack/prompts": "^0.7.0", "@oclif/core": "^1.26.2", "@oclif/errors": "^1.3.6", "@oclif/plugin-not-found": "^2.3.22", @@ -38,6 +39,7 @@ "node-fetch": "^2.0.0", "oclif": "^4.2.0", "open": "^8.4.0", + "picocolors": "^1.0.0", "reflect-metadata": "^0.1.13", "request": "^2.88.2", "serve-handler": "^6.1.3", diff --git a/src/commands/generate/fromTemplate.ts b/src/commands/generate/fromTemplate.ts index 83a39c3d1bc..3dec4adc200 100644 --- a/src/commands/generate/fromTemplate.ts +++ b/src/commands/generate/fromTemplate.ts @@ -1,4 +1,4 @@ -import { Flags, CliUx } from '@oclif/core'; +import { Flags } from '@oclif/core'; import Command from '../../base'; // eslint-disable-next-line // @ts-ignore @@ -13,11 +13,8 @@ import { ValidationError } from '../../errors/validation-error'; import { GeneratorError } from '../../errors/generator-error'; import { Parser } from '@asyncapi/parser'; import type { Example } from '@oclif/core/lib/interfaces'; - -const red = (text: string) => `\x1b[31m${text}\x1b[0m`; -const magenta = (text: string) => `\x1b[35m${text}\x1b[0m`; -const yellow = (text: string) => `\x1b[33m${text}\x1b[0m`; -const green = (text: string) => `\x1b[32m${text}\x1b[0m`; +import { intro, isCancel, spinner, text } from '@clack/prompts'; +import { inverse, yellow, magenta, green, red } from 'picocolors'; interface IMapBaseUrlToFlag { url: string, @@ -68,6 +65,11 @@ export default class Template extends Command { description: 'Disable a specific hook type or hooks from a given hook type', multiple: true }), + 'no-interactive': Flags.boolean({ + description: 'Disable interactive mode and run with the provided flags.', + required: false, + default: false, + }), install: Flags.boolean({ char: 'i', default: false, @@ -103,18 +105,27 @@ export default class Template extends Command { }; static args = [ - { name: 'asyncapi', description: '- Local path, url or context-name pointing to AsyncAPI file', required: true }, - { name: 'template', description: '- Name of the generator template like for example @asyncapi/html-template or https://github.com/asyncapi/html-template', required: true } + { name: 'asyncapi', description: '- Local path, url or context-name pointing to AsyncAPI file' }, + { name: 'template', description: '- Name of the generator template like for example @asyncapi/html-template or https://github.com/asyncapi/html-template' }, ]; parser = new Parser(); async run() { const { args, flags } = await this.parse(Template); // NOSONAR + const interactive = !flags['no-interactive']; + + let { asyncapi, template } = args; + let output = flags.output as string; + if (interactive) { + intro(inverse('AsyncAPI Generator')); + + const parsedArgs = await this.parseArgs(args, output); + asyncapi = parsedArgs.asyncapi; + template = parsedArgs.template; + output = parsedArgs.output; + } - const asyncapi = args['asyncapi']; - const template = args['template']; - const output = flags.output || process.cwd(); const parsedFlags = this.parseFlags(flags['disable-hook'], flags['param'], flags['map-base-url']); const options = { forceWrite: flags['force-write'], @@ -142,13 +153,66 @@ export default class Template extends Command { this.error(`${template} template does not support AsyncAPI v3 documents, please checkout ${v3IssueLink}`); } } - await this.generate(asyncapi, template, output, options, genOption); + await this.generate(asyncapi, template, output, options, genOption, interactive); if (watchTemplate) { - const watcherHandler = this.watcherHandler(asyncapi, template, output, options, genOption); + const watcherHandler = this.watcherHandler(asyncapi, template, output, options, genOption, interactive); await this.runWatchMode(asyncapi, template, output, watcherHandler); } } + private async parseArgs(args: Record, output?: string): Promise<{ asyncapi: string; template: string; output: string; }> { + let asyncapi = args['asyncapi']; + let template = args['template']; + const cancellationMessage = 'Operation cancelled'; + + if (!asyncapi) { + asyncapi = await text({ + message: 'Please provide the path to the AsyncAPI document', + placeholder: 'asyncapi.yaml', + defaultValue: 'asyncapi.yaml', + validate(value: string) { + if (!value) { + return 'The path to the AsyncAPI document is required'; + } else if (!fs.existsSync(value)) { + return 'The file does not exist'; + } + } + }); + } + + if (isCancel(asyncapi)) { + this.error(cancellationMessage, { exit: 1 }); + } + + if (!template) { + template = await text({ + message: 'Please provide the name of the generator template', + placeholder: '@asyncapi/html-template', + defaultValue: '@asyncapi/html-template', + }); + } + + if (!output) { + output = await text({ + message: 'Please provide the output directory', + placeholder: './docs', + validate(value: string) { + if (!value) { + return 'The output directory is required'; + } else if (typeof value !== 'string') { + return 'The output directory must be a string'; + } + } + }) as string; + } + + if (isCancel(output) || isCancel(template)) { + this.error(cancellationMessage, { exit: 1 }); + } + + return { asyncapi, template, output }; + } + private parseFlags(disableHooks?: string[], params?: string[], mapBaseUrl?: string): ParsedFlags { return { params: this.paramParser(params), @@ -204,7 +268,7 @@ export default class Template extends Command { return mapBaseURLToFolder; } - private async generate(asyncapi: string | undefined, template: string, output: string, options: any, genOption: any) { + private async generate(asyncapi: string | undefined, template: string, output: string, options: any, genOption: any, interactive = true) { let specification: Specification; try { specification = await load(asyncapi); @@ -218,16 +282,15 @@ export default class Template extends Command { ); } const generator = new AsyncAPIGenerator(template, output || path.resolve(os.tmpdir(), 'asyncapi-generator'), options); - - CliUx.ux.action.start('Generation in progress. Keep calm and wait a bit'); + const s = interactive ? spinner() : { start: () => null, stop: (string: string) => console.log(string) }; + s.start('Generation in progress. Keep calm and wait a bit'); try { await generator.generateFromString(specification.text(), genOption); - CliUx.ux.action.stop(); } catch (err: any) { - CliUx.ux.action.stop('done\n'); + s.stop('Generation failed'); throw new GeneratorError(err); } - console.log(`${yellow('Check out your shiny new generated files at ') + magenta(output) + yellow('.')}\n`); + s.stop(`${yellow('Check out your shiny new generated files at ') + magenta(output) + yellow('.')}\n`); } private async runWatchMode(asyncapi: string | undefined, template: string, output: string, watchHandler: ReturnType) { @@ -270,7 +333,7 @@ export default class Template extends Command { }); } - private watcherHandler(asyncapi: string, template: string, output: string, options: Record, genOption: any): (changedFiles: Record) => Promise { + private watcherHandler(asyncapi: string, template: string, output: string, options: Record, genOption: any, interactive: boolean): (changedFiles: Record) => Promise { return async (changedFiles: Record): Promise => { console.clear(); console.log('[WATCHER] Change detected'); @@ -292,7 +355,7 @@ export default class Template extends Command { this.log(`\t${magenta(value.path)} was ${eventText}`); } try { - await this.generate(asyncapi, template, output, options, genOption); + await this.generate(asyncapi, template, output, options, genOption, interactive); } catch (err: any) { throw new GeneratorError(err); } diff --git a/src/commands/generate/models.ts b/src/commands/generate/models.ts index 556e7d9af7b..a852b7b0209 100644 --- a/src/commands/generate/models.ts +++ b/src/commands/generate/models.ts @@ -5,6 +5,9 @@ import Command from '../../base'; import { load } from '../../models/SpecificationFile'; import { formatOutput, parse, validationFlags } from '../../parser'; +import { select, text, spinner, isCancel, cancel, intro } from '@clack/prompts'; +import { green, inverse } from 'picocolors'; + import type { AbstractGenerator, AbstractFileGenerator } from '@asyncapi/modelina'; enum Languages { @@ -29,13 +32,17 @@ export default class Models extends Command { name: 'language', description: 'The language you want the typed models generated for.', options: Object.keys(Languages), - required: true }, - { name: 'file', description: 'Path or URL to the AsyncAPI document, or context-name', required: true }, + { name: 'file', description: 'Path or URL to the AsyncAPI document, or context-name' }, ]; static flags = { help: Flags.help({ char: 'h' }), + 'no-interactive': Flags.boolean({ + description: 'Disable interactive mode and run with the provided flags.', + required: false, + default: false, + }), output: Flags.string({ char: 'o', description: 'The output directory where the models should be written to. Omitting this flag will write the models to `stdout`.', @@ -168,8 +175,21 @@ export default class Models extends Command { /* eslint-disable sonarjs/cognitive-complexity */ async run() { const { args, flags } = await this.parse(Models); - const { tsModelType, tsEnumType, tsIncludeComments, tsModuleSystem, tsExportType, tsJsonBinPack, tsMarshalling, tsExampleInstance, namespace, csharpAutoImplement, csharpArrayType, csharpNewtonsoft, csharpHashcode, csharpEqual, csharpSystemJson, packageName, javaIncludeComments, javaJackson, javaConstraints, output } = flags; - const { language, file } = args; + + const { tsModelType, tsEnumType, tsIncludeComments, tsModuleSystem, tsExportType, tsJsonBinPack, tsMarshalling, tsExampleInstance, namespace, csharpAutoImplement, csharpArrayType, csharpNewtonsoft, csharpHashcode, csharpEqual, csharpSystemJson, packageName, javaIncludeComments, javaJackson, javaConstraints } = flags; + let { language, file } = args; + let output = flags.output || 'stdout'; + const interactive = !flags['no-interactive']; + + if (!interactive) { + intro(inverse('AsyncAPI Generate Models')); + + const parsedArgs = await this.parseArgs(args, output); + language = parsedArgs.language; + file = parsedArgs.file; + output = parsedArgs.output; + } + const inputFile = (await load(file)) || (await load()); if (inputFile.isAsyncAPI3()) { this.error('Generate Models command does not support AsyncAPI v3 yet, please checkout https://github.com/asyncapi/modelina/issues/1376'); @@ -343,13 +363,15 @@ export default class Models extends Command { throw new Error(`Could not determine generator for language ${language}, are you using one of the following values ${possibleLanguageValues}?`); } - if (output) { + const s = spinner(); + s.start('Generating models...'); + if (output !== 'stdout') { const models = await fileGenerator.generateToFiles( convertedDoc as any, output, { ...fileOptions, }); const generatedModels = models.map((model) => { return model.modelName; }); - this.log(`Successfully generated the following models: ${generatedModels.join(', ')}`); + s.stop(green(`Successfully generated the following models: ${generatedModels.join(', ')}`)); return; } @@ -358,10 +380,61 @@ export default class Models extends Command { { ...fileOptions }); const generatedModels = models.map((model) => { return ` -## Model name: ${model.modelName} -${model.result} -`; + ## Model name: ${model.modelName} + ${model.result} + `; }); - this.log(`Successfully generated the following models: ${generatedModels.join('\n')}`); + s.stop(green(`Successfully generated the following models: ${generatedModels.join('\n')}`)); + } + + private async parseArgs(args: Record, output?: string) { + let { language, file } = args; + let askForOutput = false; + const operationCancelled = 'Operation cancelled by the user.'; + if (!language) { + language = await select({ + message: 'Select the language you want to generate models for', + options: Object.keys(Languages).map((key) => + ({ value: key, label: key, hint: Languages[key as keyof typeof Languages] }) + ), + }); + + askForOutput = true; + } + + if (isCancel(language)) { + cancel(operationCancelled); + this.exit(); + } + + if (!file) { + file = await text({ + message: 'Enter the path or URL to the AsyncAPI document', + defaultValue: 'asyncapi.yaml', + placeholder: 'asyncapi.yaml', + }); + + askForOutput = true; + } + + if (isCancel(file)) { + cancel(operationCancelled); + this.exit(); + } + + if (!output && askForOutput) { + output = await text({ + message: 'Enter the output directory or stdout to write the models to', + defaultValue: 'stdout', + placeholder: 'stdout', + }) as string; + } + + if (isCancel(output)) { + cancel(operationCancelled); + this.exit(); + } + + return { language, file, output: output || 'stdout' }; } } diff --git a/src/models/SpecificationFile.ts b/src/models/SpecificationFile.ts index e1c55899266..857ac471cf4 100644 --- a/src/models/SpecificationFile.ts +++ b/src/models/SpecificationFile.ts @@ -122,24 +122,25 @@ interface LoadType { /* eslint-disable sonarjs/cognitive-complexity */ export async function load(filePathOrContextName?: string, loadType?: LoadType): Promise { // NOSONAR - if (filePathOrContextName) { - if (loadType?.file) { return Specification.fromFile(filePathOrContextName); } - if (loadType?.context) { return loadFromContext(filePathOrContextName); } - if (loadType?.url) { return Specification.fromURL(filePathOrContextName); } - - const type = await nameType(filePathOrContextName); - if (type === TYPE_CONTEXT_NAME) { - return loadFromContext(filePathOrContextName); - } + try { + if (filePathOrContextName) { + if (loadType?.file) { return Specification.fromFile(filePathOrContextName); } + if (loadType?.context) { return loadFromContext(filePathOrContextName); } + if (loadType?.url) { return Specification.fromURL(filePathOrContextName); } + + const type = await nameType(filePathOrContextName); + if (type === TYPE_CONTEXT_NAME) { + return loadFromContext(filePathOrContextName); + } - if (type === TYPE_URL) { - return Specification.fromURL(filePathOrContextName); + if (type === TYPE_URL) { + return Specification.fromURL(filePathOrContextName); + } + await fileExists(filePathOrContextName); + + return Specification.fromFile(filePathOrContextName); } - await fileExists(filePathOrContextName); - return Specification.fromFile(filePathOrContextName); - } - try { return await loadFromContext(); } catch (e) { const autoDetectedSpecFile = await detectSpecFile(); diff --git a/test/integration/generate/fromTemplate.test.ts b/test/integration/generate/fromTemplate.test.ts index fe5e0cd126a..7c7dd6518d8 100644 --- a/test/integration/generate/fromTemplate.test.ts +++ b/test/integration/generate/fromTemplate.test.ts @@ -4,6 +4,8 @@ import { test } from '@oclif/test'; import rimraf from 'rimraf'; import { expect } from '@oclif/test'; +const nonInteractive = '--no-interactive'; + const generalOptions = [ 'generate:fromTemplate', './test/fixtures/specification.yml', @@ -21,8 +23,9 @@ describe('template', () => { }); test .stdout() - .command([...generalOptions, '--output=./test/docs/1', '--force-write']) + .command([...generalOptions, '--output=./test/docs/1', '--force-write', '--no-interactive']) .it('should generate minimal template', (ctx, done) => { + console.log(ctx.stdout); expect(ctx.stdout).to.contain( 'Check out your shiny new generated files at ./test/docs/1.\n\n' ); @@ -37,7 +40,9 @@ describe('template', () => { .command([ 'generate:fromTemplate', asyncapiv3, - '@asyncapi/minimaltemplate']) + '@asyncapi/minimaltemplate', + nonInteractive, + ]) .it('give error on disabled template', (ctx, done) => { expect(ctx.stderr).to.equal('Error: @asyncapi/minimaltemplate template does not support AsyncAPI v3 documents, please checkout some link\n'); expect(ctx.stdout).to.equal(''); @@ -54,7 +59,7 @@ describe('template', () => { }); test .stderr() - .command([...generalOptions, `--output=${pathToOutput}`]) + .command([...generalOptions, `--output=${pathToOutput}`, nonInteractive]) .it( 'should throw error if output folder is in a git repository', (ctx, done) => { @@ -75,6 +80,7 @@ describe('template', () => { '-p=version=1.0.0 mode=development', '--output=./test/docs/3', '--force-write', + nonInteractive ]) .it('should pass custom param in the template', (ctx, done) => { expect(ctx.stdout).to.contain( @@ -93,6 +99,7 @@ describe('template', () => { '--output=./test/docs/4', '--force-write', '-d=generate:after', + nonInteractive ]) .it('should not create asyncapi.yaml file', async (_, done) => { const exits = fs.existsSync(path.resolve('./docs/asyncapi.yaml')); @@ -110,6 +117,7 @@ describe('template', () => { '--output=./test/docs/5', '--force-write', '--debug', + nonInteractive ]) .it('should print debug logs', (ctx, done) => { expect(ctx.stdout).to.contain( @@ -130,6 +138,7 @@ describe('template', () => { '--output=./test/docs/6', '--force-write', '--no-overwrite=./test/docs/asyncapi.md', + nonInteractive ]) .it('should skip the filepath and generate normally', (ctx, done) => { expect(ctx.stdout).to.contain(