Browse Source

refactor(cli): Make chained commands more reliable

Michael Bromley 1 year ago
parent
commit
47b04c287a

+ 14 - 3
packages/cli/src/commands/add/add.ts

@@ -1,7 +1,9 @@
-import { cancel, isCancel, log, outro, select } from '@clack/prompts';
+import { cancel, intro, isCancel, log, outro, select, spinner } from '@clack/prompts';
 import { Command } from 'commander';
+import pc from 'picocolors';
 
 import { CliCommand } from '../../shared/cli-command';
+import { pauseForPromptDisplay } from '../../utilities/utils';
 
 import { addApiExtensionCommand } from './api-extension/add-api-extension';
 import { addCodegenCommand } from './codegen/add-codegen';
@@ -18,6 +20,9 @@ export function registerAddCommand(program: Command) {
         .command('add')
         .description('Add a feature to your Vendure project')
         .action(async () => {
+            // eslint-disable-next-line no-console
+            console.log(`\n`);
+            intro(pc.blue("✨ Let's add a new feature to your Vendure project!"));
             const addCommands: Array<CliCommand<any>> = [
                 createNewPluginCommand,
                 addEntityCommand,
@@ -45,8 +50,14 @@ export function registerAddCommand(program: Command) {
                 }
                 const { modifiedSourceFiles } = await command.run();
 
-                for (const sourceFile of modifiedSourceFiles) {
-                    sourceFile.organizeImports();
+                if (modifiedSourceFiles.length) {
+                    const importsSpinner = spinner();
+                    importsSpinner.start('Organizing imports...');
+                    await pauseForPromptDisplay();
+                    for (const sourceFile of modifiedSourceFiles) {
+                        sourceFile.organizeImports();
+                    }
+                    importsSpinner.stop('Imports organized');
                 }
                 outro('✅ Done!');
             } catch (e: any) {

+ 31 - 33
packages/cli/src/commands/add/api-extension/add-api-extension.ts

@@ -17,6 +17,7 @@ import { ServiceRef } from '../../../shared/service-ref';
 import { analyzeProject, selectPlugin, selectServiceRef } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { addImportsToFile, createFile } from '../../../utilities/ast-utils';
+import { pauseForPromptDisplay } from '../../../utilities/utils';
 
 const cancelledMessage = 'Add API extension cancelled';
 
@@ -45,51 +46,48 @@ async function addApiExtension(
     let apiExtensions: VariableDeclaration | undefined;
 
     const scaffoldSpinner = spinner();
-    scaffoldSpinner.start('Generating API extension files...');
-    await new Promise(resolve => setTimeout(resolve, 100));
 
+    scaffoldSpinner.start('Generating resolver file...');
+    await pauseForPromptDisplay();
     if (serviceEntityRef) {
         resolver = createCrudResolver(project, plugin, serviceRef, serviceEntityRef);
         modifiedSourceFiles.push(resolver.getSourceFile());
-        apiExtensions = createCrudApiExtension(project, plugin, serviceRef);
-        if (apiExtensions) {
-            modifiedSourceFiles.push(apiExtensions.getSourceFile());
-        }
-        plugin.addAdminApiExtensions({
-            schema: apiExtensions,
-            resolvers: [resolver],
-        });
-        addImportsToFile(plugin.getSourceFile(), {
-            namedImports: [resolver.getName() as string],
-            moduleSpecifier: resolver.getSourceFile(),
-        });
-        if (apiExtensions) {
-            addImportsToFile(plugin.getSourceFile(), {
-                namedImports: [apiExtensions.getName()],
-                moduleSpecifier: apiExtensions.getSourceFile(),
-            });
-        }
     } else {
         resolver = createSimpleResolver(project, plugin, serviceRef);
         modifiedSourceFiles.push(resolver.getSourceFile());
+    }
+
+    scaffoldSpinner.message('Generating schema definitions...');
+    await pauseForPromptDisplay();
+
+    if (serviceEntityRef) {
+        apiExtensions = createCrudApiExtension(project, plugin, serviceRef);
+    } else {
         apiExtensions = createSimpleApiExtension(project, plugin, serviceRef);
-        plugin.addAdminApiExtensions({
-            schema: apiExtensions,
-            resolvers: [resolver],
-        });
+    }
+    if (apiExtensions) {
+        modifiedSourceFiles.push(apiExtensions.getSourceFile());
+    }
+
+    scaffoldSpinner.message('Registering API extension with plugin...');
+    await pauseForPromptDisplay();
+
+    plugin.addAdminApiExtensions({
+        schema: apiExtensions,
+        resolvers: [resolver],
+    });
+    addImportsToFile(plugin.getSourceFile(), {
+        namedImports: [resolver.getName() as string],
+        moduleSpecifier: resolver.getSourceFile(),
+    });
+    if (apiExtensions) {
         addImportsToFile(plugin.getSourceFile(), {
-            namedImports: [resolver.getName() as string],
-            moduleSpecifier: resolver.getSourceFile(),
+            namedImports: [apiExtensions.getName()],
+            moduleSpecifier: apiExtensions.getSourceFile(),
         });
-        if (apiExtensions) {
-            addImportsToFile(plugin.getSourceFile(), {
-                namedImports: [apiExtensions.getName()],
-                moduleSpecifier: apiExtensions.getSourceFile(),
-            });
-        }
     }
 
-    scaffoldSpinner.stop(`API extension files generated`);
+    scaffoldSpinner.stop(`API extensions added`);
 
     await project.save();
 

+ 2 - 1
packages/cli/src/commands/add/codegen/add-codegen.ts

@@ -7,6 +7,7 @@ import { PackageJson } from '../../../shared/package-json-ref';
 import { analyzeProject, selectMultiplePluginClasses } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { getRelativeImportPath } from '../../../utilities/ast-utils';
+import { pauseForPromptDisplay } from '../../../utilities/utils';
 
 import { CodegenConfigRef } from './codegen-config-ref';
 
@@ -59,7 +60,7 @@ async function addCodegen(options?: AddCodegenOptions): Promise<CliCommandReturn
 
     const configSpinner = spinner();
     configSpinner.start('Configuring codegen file...');
-    await new Promise(resolve => setTimeout(resolve, 100));
+    await pauseForPromptDisplay();
 
     const codegenFile = new CodegenConfigRef(packageJson.getPackageRootDir());
 

+ 6 - 3
packages/cli/src/commands/add/entity/add-entity.ts

@@ -1,4 +1,4 @@
-import { cancel, isCancel, multiselect, outro, text } from '@clack/prompts';
+import { cancel, isCancel, multiselect, spinner, text } from '@clack/prompts';
 import { paramCase, pascalCase } from 'change-case';
 import path from 'path';
 import { ClassDeclaration } from 'ts-morph';
@@ -48,14 +48,17 @@ async function addEntity(
         features: await getFeatures(options),
     };
 
+    const entitySpinner = spinner();
+    entitySpinner.start('Creating entity...');
+
     const { entityClass, translationClass } = createEntity(vendurePlugin, context);
     addEntityToPlugin(vendurePlugin, entityClass);
-    entityClass.getSourceFile().organizeImports();
     if (context.features.translatable) {
         addEntityToPlugin(vendurePlugin, translationClass);
-        translationClass.getSourceFile().organizeImports();
     }
 
+    entitySpinner.stop('Entity created');
+
     await project.save();
 
     return {

+ 14 - 7
packages/cli/src/commands/add/job-queue/add-job-queue.ts

@@ -1,4 +1,4 @@
-import { cancel, isCancel, text } from '@clack/prompts';
+import { cancel, isCancel, log, text } from '@clack/prompts';
 import { camelCase, pascalCase } from 'change-case';
 import { Node, Scope } from 'ts-morph';
 
@@ -64,8 +64,10 @@ async function addJobQueue(
         type: 'JobQueueService',
     });
 
-    serviceRef.classDeclaration.addProperty({
-        name: camelCase(jobQueueName),
+    const jobQueuePropertyName = camelCase(jobQueueName) + 'Queue';
+
+    serviceRef.classDeclaration.insertProperty(0, {
+        name: jobQueuePropertyName,
         scope: Scope.Private,
         type: writer => writer.write('JobQueue<{ ctx: SerializedRequestContext, someArg: string; }>'),
     });
@@ -73,13 +75,16 @@ async function addJobQueue(
     serviceRef.classDeclaration.addImplements('OnModuleInit');
     let onModuleInitMethod = serviceRef.classDeclaration.getMethod('onModuleInit');
     if (!onModuleInitMethod) {
-        onModuleInitMethod = serviceRef.classDeclaration.addMethod({
+        // Add this after the constructor
+        const constructor = serviceRef.classDeclaration.getConstructors()[0];
+        const constructorChildIndex = constructor?.getChildIndex() ?? 0;
+
+        onModuleInitMethod = serviceRef.classDeclaration.insertMethod(constructorChildIndex + 1, {
             name: 'onModuleInit',
             isAsync: false,
             returnType: 'void',
             scope: Scope.Public,
         });
-        onModuleInitMethod.setScope(Scope.Private);
     }
     onModuleInitMethod.setIsAsync(true);
     onModuleInitMethod.setReturnType('Promise<void>');
@@ -88,7 +93,7 @@ async function addJobQueue(
         body.addStatements(writer => {
             writer
                 .write(
-                    `this.${camelCase(jobQueueName)} = await this.jobQueueService.createQueue({
+                    `this.${jobQueuePropertyName} = await this.jobQueueService.createQueue({
                 name: '${jobQueueName}',
                 process: async job => {
                     // Deserialize the RequestContext from the job data
@@ -133,7 +138,7 @@ async function addJobQueue(
             scope: Scope.Public,
             parameters: [{ name: 'ctx', type: 'RequestContext' }],
             statements: writer => {
-                writer.write(`return this.${camelCase(jobQueueName)}.add({
+                writer.write(`return this.${jobQueuePropertyName}.add({
                 ctx: ctx.serialize(),
                 someArg: 'foo',
             })`);
@@ -141,6 +146,8 @@ async function addJobQueue(
         })
         .formatText();
 
+    log.success(`New job queue created in ${serviceRef.name}`);
+
     await project.save();
 
     return { project, modifiedSourceFiles: [serviceRef.classDeclaration.getSourceFile()], serviceRef };

+ 35 - 9
packages/cli/src/commands/add/plugin/create-new-plugin.ts

@@ -1,4 +1,4 @@
-import { cancel, intro, isCancel, select, spinner, text } from '@clack/prompts';
+import { cancel, intro, isCancel, log, select, spinner, text } from '@clack/prompts';
 import { constantCase, paramCase, pascalCase } from 'change-case';
 import * as fs from 'fs-extra';
 import path from 'path';
@@ -7,6 +7,7 @@ import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { addImportsToFile, createFile, getTsMorphProject } from '../../../utilities/ast-utils';
+import { pauseForPromptDisplay } from '../../../utilities/utils';
 import { addApiExtensionCommand } from '../api-extension/add-api-extension';
 import { addCodegenCommand } from '../codegen/add-codegen';
 import { addEntityCommand } from '../entity/add-entity';
@@ -68,8 +69,8 @@ export async function createNewPlugin(): Promise<CliCommandReturnVal> {
 
     const configSpinner = spinner();
     configSpinner.start('Updating VendureConfig...');
-    await new Promise(resolve => setTimeout(resolve, 100));
-    const vendureConfig = new VendureConfigRef(plugin.classDeclaration.getProject());
+    await pauseForPromptDisplay();
+    const vendureConfig = new VendureConfigRef(project);
     vendureConfig.addToPluginsArray(`${plugin.name}.init({})`);
     addImportsToFile(vendureConfig.sourceFile, {
         moduleSpecifier: plugin.getSourceFile(),
@@ -87,7 +88,10 @@ export async function createNewPlugin(): Promise<CliCommandReturnVal> {
         addUiExtensionsCommand,
         addCodegenCommand,
     ];
-    const allModifiedSourceFiles = [...modifiedSourceFiles];
+    let allModifiedSourceFiles = [...modifiedSourceFiles];
+    const pluginClassName = plugin.name;
+    let workingPlugin = plugin;
+    let workingProject = project;
     while (!done) {
         const featureType = await select({
             message: `Add features to ${options.name}?`,
@@ -105,16 +109,38 @@ export async function createNewPlugin(): Promise<CliCommandReturnVal> {
         if (featureType === 'no') {
             done = true;
         } else {
-            const command = followUpCommands.find(c => c.id === featureType);
+            const newProject = getTsMorphProject();
+            workingProject = newProject;
+            const newPlugin = newProject
+                .getSourceFile(workingPlugin.getSourceFile().getFilePath())
+                ?.getClass(pluginClassName);
+            if (!newPlugin) {
+                throw new Error(`Could not find class "${pluginClassName}" in the new project`);
+            }
+            workingPlugin = new VendurePluginRef(newPlugin);
+            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+            const command = followUpCommands.find(c => c.id === featureType)!;
             // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            const result = await command!.run({ plugin });
-            allModifiedSourceFiles.push(...result.modifiedSourceFiles);
+            try {
+                const result = await command.run({ plugin: new VendurePluginRef(newPlugin) });
+                allModifiedSourceFiles = result.modifiedSourceFiles;
+                // We format all modified source files and re-load the
+                // project to avoid issues with the project state
+                for (const sourceFile of allModifiedSourceFiles) {
+                    sourceFile.organizeImports();
+                }
+            } catch (e: any) {
+                log.error(`Error adding feature "${command.id}"`);
+                log.error(e.stack);
+            }
+
+            await workingProject.save();
         }
     }
 
     return {
         project,
-        modifiedSourceFiles,
+        modifiedSourceFiles: [],
     };
 }
 
@@ -131,7 +157,7 @@ export async function generatePlugin(
 
     const projectSpinner = spinner();
     projectSpinner.start('Generating plugin scaffold...');
-    await new Promise(resolve => setTimeout(resolve, 100));
+    await pauseForPromptDisplay();
     const project = getTsMorphProject({ skipAddingFilesFromTsConfig: true });
 
     const pluginFile = createFile(project, path.join(__dirname, 'templates/plugin.template.ts'));

+ 35 - 9
packages/cli/src/commands/add/service/add-service.ts

@@ -1,15 +1,17 @@
-import { cancel, isCancel, select, text } from '@clack/prompts';
+import { cancel, isCancel, log, select, spinner, text } from '@clack/prompts';
 import { paramCase } from 'change-case';
 import path from 'path';
 import { ClassDeclaration, SourceFile } from 'ts-morph';
 
-import { pascalCaseRegex } from '../../../constants';
+import { Messages, pascalCaseRegex } from '../../../constants';
 import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { EntityRef } from '../../../shared/entity-ref';
 import { ServiceRef } from '../../../shared/service-ref';
 import { analyzeProject, selectEntity, selectPlugin } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { addImportsToFile, createFile } from '../../../utilities/ast-utils';
+import { pauseForPromptDisplay } from '../../../utilities/utils';
+import { addEntityCommand } from '../entity/add-entity';
 
 const cancelledMessage = 'Add service cancelled';
 
@@ -33,7 +35,7 @@ async function addService(
     const providedVendurePlugin = providedOptions?.plugin;
     const project = await analyzeProject({ providedVendurePlugin, cancelledMessage });
     const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
-
+    const modifiedSourceFiles: SourceFile[] = [];
     const type =
         providedOptions?.type ??
         (await select({
@@ -53,15 +55,28 @@ async function addService(
         serviceName: 'MyService',
     };
     if (type === 'entity') {
-        const entityRef = await selectEntity(vendurePlugin);
+        let entityRef: EntityRef;
+        try {
+            entityRef = await selectEntity(vendurePlugin);
+        } catch (e: any) {
+            if (e.message === Messages.NoEntitiesFound) {
+                log.info(`No entities found in plugin ${vendurePlugin.name}. Let's create one first.`);
+                const result = await addEntityCommand.run({ plugin: providedVendurePlugin });
+                entityRef = result.entityRef;
+                modifiedSourceFiles.push(...result.modifiedSourceFiles);
+            } else {
+                throw e;
+            }
+        }
         options.entityRef = entityRef;
         options.serviceName = `${entityRef.name}Service`;
     }
 
+    const serviceSpinner = spinner();
+
     let serviceSourceFile: SourceFile;
     let serviceClassDeclaration: ClassDeclaration;
     if (options.type === 'basic') {
-        serviceSourceFile = createFile(project, path.join(__dirname, 'templates/basic-service.template.ts'));
         const name = await text({
             message: 'What is the name of the new service?',
             initialValue: 'MyService',
@@ -74,16 +89,23 @@ async function addService(
                 }
             },
         });
+
         if (isCancel(name)) {
             cancel(cancelledMessage);
             process.exit(0);
         }
+
         options.serviceName = name;
+        serviceSpinner.start(`Creating ${options.serviceName}...`);
+        await pauseForPromptDisplay();
+        serviceSourceFile = createFile(project, path.join(__dirname, 'templates/basic-service.template.ts'));
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         serviceClassDeclaration = serviceSourceFile
             .getClass('BasicServiceTemplate')!
             .rename(options.serviceName);
     } else {
+        serviceSpinner.start(`Creating ${options.serviceName}...`);
+        await pauseForPromptDisplay();
         serviceSourceFile = createFile(project, path.join(__dirname, 'templates/entity-service.template.ts'));
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         serviceClassDeclaration = serviceSourceFile
@@ -124,12 +146,14 @@ async function addService(
         customizeUpdateMethod(serviceClassDeclaration, entityRef);
         removedUnusedConstructorArgs(serviceClassDeclaration, entityRef);
     }
-
+    modifiedSourceFiles.push(serviceSourceFile);
     const serviceFileName = paramCase(options.serviceName).replace(/-service$/, '.service');
     serviceSourceFile?.move(
         path.join(vendurePlugin.getPluginDir().getPath(), 'services', `${serviceFileName}.ts`),
     );
 
+    serviceSpinner.message(`Registering service with plugin...`);
+
     vendurePlugin.addProvider(options.serviceName);
     addImportsToFile(vendurePlugin.classDeclaration.getSourceFile(), {
         moduleSpecifier: serviceSourceFile,
@@ -138,9 +162,11 @@ async function addService(
 
     await project.save();
 
+    serviceSpinner.stop(`${options.serviceName} created`);
+
     return {
         project,
-        modifiedSourceFiles: [serviceSourceFile],
+        modifiedSourceFiles,
         serviceRef: new ServiceRef(serviceClassDeclaration),
     };
 }
@@ -228,7 +254,7 @@ function customizeCreateMethod(serviceClassDeclaration: ClassDeclaration, entity
         })
         .formatText();
     if (!entityRef.isTranslatable()) {
-        createMethod.setReturnType(`Promise<${entityRef.name} | null>`);
+        createMethod.setReturnType(`Promise<${entityRef.name}>`);
     }
 }
 
@@ -265,7 +291,7 @@ function customizeUpdateMethod(serviceClassDeclaration: ClassDeclaration, entity
         })
         .formatText();
     if (!entityRef.isTranslatable()) {
-        updateMethod.setReturnType(`Promise<${entityRef.name} | null>`);
+        updateMethod.setReturnType(`Promise<${entityRef.name}>`);
     }
 }
 

+ 3 - 3
packages/cli/src/shared/shared-prompts.ts

@@ -4,6 +4,7 @@ import { ClassDeclaration, Project } from 'ts-morph';
 import { addServiceCommand } from '../commands/add/service/add-service';
 import { Messages } from '../constants';
 import { getPluginClasses, getTsMorphProject } from '../utilities/ast-utils';
+import { pauseForPromptDisplay } from '../utilities/utils';
 
 import { EntityRef } from './entity-ref';
 import { ServiceRef } from './service-ref';
@@ -18,7 +19,7 @@ export async function analyzeProject(options: {
     if (!providedVendurePlugin) {
         const projectSpinner = spinner();
         projectSpinner.start('Analyzing project...');
-        await new Promise(resolve => setTimeout(resolve, 100));
+        await pauseForPromptDisplay();
         project = getTsMorphProject();
         projectSpinner.stop('Project analyzed');
     }
@@ -49,8 +50,7 @@ export async function selectPlugin(project: Project, cancelledMessage: string):
 export async function selectEntity(plugin: VendurePluginRef): Promise<EntityRef> {
     const entities = plugin.getEntities();
     if (entities.length === 0) {
-        cancel(Messages.NoEntitiesFound);
-        process.exit(0);
+        throw new Error(Messages.NoEntitiesFound);
     }
     const targetEntity = await select({
         message: 'Select an entity',

+ 1 - 0
packages/cli/src/utilities/ast-utils.ts

@@ -13,6 +13,7 @@ export function getTsMorphProject(options: ProjectOptions = {}) {
     return new Project({
         tsConfigFilePath: tsConfigPath,
         manipulationSettings: defaultManipulationSettings,
+        skipFileDependencyResolution: true,
         compilerOptions: {
             skipLibCheck: true,
         },

+ 7 - 0
packages/cli/src/utilities/utils.ts

@@ -0,0 +1,7 @@
+/**
+ * Since the AST manipulation is blocking, prompts will not get a
+ * chance to be displayed unless we give a small async pause.
+ */
+export async function pauseForPromptDisplay() {
+    await new Promise(resolve => setTimeout(resolve, 100));
+}