Explorar o código

fix(cli): Various fixes to CLI add commands

Michael Bromley hai 1 ano
pai
achega
4ea7711f49

+ 4 - 1
packages/cli/src/commands/add/add.ts

@@ -2,6 +2,7 @@ import { cancel, intro, isCancel, log, outro, select, spinner } from '@clack/pro
 import { Command } from 'commander';
 import pc from 'picocolors';
 
+import { Messages } from '../../constants';
 import { CliCommand } from '../../shared/cli-command';
 import { pauseForPromptDisplay } from '../../utilities/utils';
 
@@ -63,9 +64,11 @@ export function registerAddCommand(program: Command) {
                 outro('✅ Done!');
             } catch (e: any) {
                 log.error(e.message as string);
-                if (e.stack) {
+                const isCliMessage = Object.values(Messages).includes(e.message);
+                if (!isCliMessage && e.stack) {
                     log.error(e.stack);
                 }
+                outro('❌ Error');
             }
             process.exit(0);
         });

+ 202 - 52
packages/cli/src/commands/add/api-extension/add-api-extension.ts

@@ -1,11 +1,14 @@
-import { spinner } from '@clack/prompts';
+import { cancel, isCancel, log, spinner, text } from '@clack/prompts';
 import { paramCase } from 'change-case';
 import path from 'path';
 import {
     ClassDeclaration,
+    CodeBlockWriter,
+    Expression,
     Node,
     Project,
     SourceFile,
+    SyntaxKind,
     Type,
     VariableDeclaration,
     VariableDeclarationKind,
@@ -42,8 +45,7 @@ async function addApiExtension(
     const providedVendurePlugin = options?.plugin;
     const project = await analyzeProject({ providedVendurePlugin, cancelledMessage });
     const plugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
-    const serviceRef = await selectServiceRef(project, plugin);
-
+    const serviceRef = await selectServiceRef(project, plugin, false);
     const serviceEntityRef = serviceRef.crudEntityRef;
     const modifiedSourceFiles: SourceFile[] = [];
     let resolver: ClassDeclaration | undefined;
@@ -51,13 +53,65 @@ async function addApiExtension(
 
     const scaffoldSpinner = spinner();
 
+    let queryName = '';
+    let mutationName = '';
+    if (!serviceEntityRef) {
+        const queryNameResult = await text({
+            message: 'Enter a name for the new query',
+            initialValue: 'myNewQuery',
+        });
+        if (!isCancel(queryNameResult)) {
+            queryName = queryNameResult;
+        }
+        const mutationNameResult = await text({
+            message: 'Enter a name for the new mutation',
+            initialValue: 'myNewMutation',
+        });
+        if (!isCancel(mutationNameResult)) {
+            mutationName = mutationNameResult;
+        }
+    }
+
     scaffoldSpinner.start('Generating resolver file...');
     await pauseForPromptDisplay();
     if (serviceEntityRef) {
         resolver = createCrudResolver(project, plugin, serviceRef, serviceEntityRef);
         modifiedSourceFiles.push(resolver.getSourceFile());
     } else {
-        resolver = createSimpleResolver(project, plugin, serviceRef);
+        if (isCancel(queryName)) {
+            cancel(cancelledMessage);
+            process.exit(0);
+        }
+        resolver = createSimpleResolver(project, plugin, serviceRef, queryName, mutationName);
+        if (queryName) {
+            serviceRef.classDeclaration.addMethod({
+                name: queryName,
+                parameters: [
+                    { name: 'ctx', type: 'RequestContext' },
+                    { name: 'id', type: 'ID' },
+                ],
+                isAsync: true,
+                returnType: 'Promise<boolean>',
+                statements: `return true;`,
+            });
+        }
+        if (mutationName) {
+            serviceRef.classDeclaration.addMethod({
+                name: mutationName,
+                parameters: [
+                    { name: 'ctx', type: 'RequestContext' },
+                    { name: 'id', type: 'ID' },
+                ],
+                isAsync: true,
+                returnType: 'Promise<boolean>',
+                statements: `return true;`,
+            });
+        }
+
+        addImportsToFile(serviceRef.classDeclaration.getSourceFile(), {
+            namedImports: ['RequestContext', 'ID'],
+            moduleSpecifier: '@vendure/core',
+        });
         modifiedSourceFiles.push(resolver.getSourceFile());
     }
 
@@ -67,7 +121,7 @@ async function addApiExtension(
     if (serviceEntityRef) {
         apiExtensions = createCrudApiExtension(project, plugin, serviceRef);
     } else {
-        apiExtensions = createSimpleApiExtension(project, plugin, serviceRef);
+        apiExtensions = createSimpleApiExtension(project, plugin, serviceRef, queryName, mutationName);
     }
     if (apiExtensions) {
         modifiedSourceFiles.push(apiExtensions.getSourceFile());
@@ -102,22 +156,70 @@ async function addApiExtension(
     };
 }
 
-function createSimpleResolver(project: Project, plugin: VendurePluginRef, serviceRef: ServiceRef) {
+function getResolverFileName(
+    project: Project,
+    serviceRef: ServiceRef,
+): { resolverFileName: string; suffix: number | undefined } {
+    let suffix: number | undefined;
+    let resolverFileName = '';
+    let sourceFileExists = false;
+    do {
+        resolverFileName =
+            paramCase(serviceRef.name).replace('-service', '') +
+            `-admin.resolver${typeof suffix === 'number' ? `-${suffix?.toString()}` : ''}.ts`;
+        sourceFileExists = !!project.getSourceFile(resolverFileName);
+        if (sourceFileExists) {
+            suffix = (suffix ?? 1) + 1;
+        }
+    } while (sourceFileExists);
+    return { resolverFileName, suffix };
+}
+
+function createSimpleResolver(
+    project: Project,
+    plugin: VendurePluginRef,
+    serviceRef: ServiceRef,
+    queryName: string,
+    mutationName: string,
+) {
+    const { resolverFileName, suffix } = getResolverFileName(project, serviceRef);
     const resolverSourceFile = createFile(
         project,
         path.join(__dirname, 'templates/simple-resolver.template.ts'),
     );
-    resolverSourceFile.move(
-        path.join(
-            plugin.getPluginDir().getPath(),
-            'api',
-            paramCase(serviceRef.name).replace('-service', '') + '-admin.resolver.ts',
-        ),
-    );
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    resolverSourceFile.move(path.join(plugin.getPluginDir().getPath(), 'api', resolverFileName));
+
     const resolverClassDeclaration = resolverSourceFile
-        .getClass('SimpleAdminResolver')!
-        .rename(serviceRef.name.replace(/Service$/, '') + 'AdminResolver');
+        .getClasses()
+        .find(cl => cl.getDecorator('Resolver') != null);
+
+    if (!resolverClassDeclaration) {
+        throw new Error('Could not find resolver class declaration');
+    }
+    if (resolverClassDeclaration.getName() === 'SimpleAdminResolver') {
+        resolverClassDeclaration.rename(
+            serviceRef.name.replace(/Service$/, '') + 'AdminResolver' + (suffix ? suffix.toString() : ''),
+        );
+    }
+
+    if (queryName) {
+        resolverSourceFile.getClass('TemplateService')?.getMethod('exampleQueryHandler')?.rename(queryName);
+        resolverClassDeclaration.getMethod('exampleQuery')?.rename(queryName);
+    } else {
+        resolverSourceFile.getClass('TemplateService')?.getMethod('exampleQueryHandler')?.remove();
+        resolverClassDeclaration.getMethod('exampleQuery')?.remove();
+    }
+
+    if (mutationName) {
+        resolverSourceFile
+            .getClass('TemplateService')
+            ?.getMethod('exampleMutationHandler')
+            ?.rename(mutationName);
+        resolverClassDeclaration.getMethod('exampleMutation')?.rename(mutationName);
+    } else {
+        resolverSourceFile.getClass('TemplateService')?.getMethod('exampleMutationHandler')?.remove();
+        resolverClassDeclaration.getMethod('exampleMutation')?.remove();
+    }
 
     resolverClassDeclaration
         .getConstructors()[0]
@@ -236,34 +338,70 @@ function createCrudResolver(
     return resolverClassDeclaration;
 }
 
-function createSimpleApiExtension(project: Project, plugin: VendurePluginRef, serviceRef: ServiceRef) {
+function createSimpleApiExtension(
+    project: Project,
+    plugin: VendurePluginRef,
+    serviceRef: ServiceRef,
+    queryName: string,
+    mutationName: string,
+) {
     const apiExtensionsFile = getOrCreateApiExtensionsFile(project, plugin);
-    const adminApiExtensionDocuments = apiExtensionsFile.getVariableDeclaration('adminApiExtensionDocuments');
-    const insertAtIndex = adminApiExtensionDocuments?.getParent().getParent().getChildIndex() ?? 2;
+    const adminApiExtensions = apiExtensionsFile.getVariableDeclaration('adminApiExtensions');
+    const insertAtIndex = adminApiExtensions?.getParent().getParent().getChildIndex() ?? 2;
     const schemaVariableName = `${serviceRef.nameCamelCase.replace(/Service$/, '')}AdminApiExtensions`;
-    apiExtensionsFile.insertVariableStatement(insertAtIndex, {
-        declarationKind: VariableDeclarationKind.Const,
-        declarations: [
-            {
-                name: schemaVariableName,
-                initializer: writer => {
-                    writer.writeLine(`gql\``);
-                    writer.indent(() => {
+    const existingSchemaVariable = apiExtensionsFile.getVariableStatement(schemaVariableName);
+    if (!existingSchemaVariable) {
+        apiExtensionsFile.insertVariableStatement(insertAtIndex, {
+            declarationKind: VariableDeclarationKind.Const,
+            declarations: [
+                {
+                    name: schemaVariableName,
+                    initializer: writer => {
+                        writer.writeLine(`gql\``);
+                        writer.indent(() => {
+                            if (queryName) {
+                                writer.writeLine(`  extend type Query {`);
+                                writer.writeLine(`    ${queryName}(id: ID!): Boolean!`);
+                                writer.writeLine(`  }`);
+                            }
+                            writer.newLine();
+                            if (mutationName) {
+                                writer.writeLine(`  extend type Mutation {`);
+                                writer.writeLine(`    ${mutationName}(id: ID!): Boolean!`);
+                                writer.writeLine(`  }`);
+                            }
+                        });
+                        writer.write(`\``);
+                    },
+                },
+            ],
+        });
+    } else {
+        const taggedTemplateLiteral = existingSchemaVariable
+            .getDeclarations()[0]
+            ?.getFirstChildByKind(SyntaxKind.TaggedTemplateExpression)
+            ?.getChildren()[1];
+        if (!taggedTemplateLiteral) {
+            log.error('Could not update schema automatically');
+        } else {
+            appendToGqlTemplateLiteral(existingSchemaVariable.getDeclarations()[0], writer => {
+                writer.indent(() => {
+                    if (queryName) {
                         writer.writeLine(`  extend type Query {`);
-                        writer.writeLine(`    exampleQuery(id: ID!): Boolean!`);
+                        writer.writeLine(`    ${queryName}(id: ID!): Boolean!`);
                         writer.writeLine(`  }`);
-                        writer.newLine();
+                    }
+                    writer.newLine();
+                    if (mutationName) {
                         writer.writeLine(`  extend type Mutation {`);
-                        writer.writeLine(`    exampleMutation(id: ID!): Boolean!`);
+                        writer.writeLine(`    ${mutationName}(id: ID!): Boolean!`);
                         writer.writeLine(`  }`);
-                    });
-                    writer.write(`\``);
-                },
-            },
-        ],
-    });
+                    }
+                });
+            });
+        }
+    }
 
-    const adminApiExtensions = apiExtensionsFile.getVariableDeclaration('adminApiExtensions');
     addSchemaToApiExtensionsTemplateLiteral(adminApiExtensions, schemaVariableName);
 
     return adminApiExtensions;
@@ -388,22 +526,34 @@ function addSchemaToApiExtensionsTemplateLiteral(
     schemaVariableName: string,
 ) {
     if (adminApiExtensions) {
-        const apiExtensionsInitializer = adminApiExtensions.getInitializer();
-        if (Node.isTaggedTemplateExpression(apiExtensionsInitializer)) {
-            adminApiExtensions
-                .setInitializer(writer => {
-                    writer.writeLine(`gql\``);
-                    const template = apiExtensionsInitializer.getTemplate();
-                    if (Node.isNoSubstitutionTemplateLiteral(template)) {
-                        writer.write(`${template.getLiteralValue()}`);
-                    } else {
-                        writer.write(template.getText().replace(/^`/, '').replace(/`$/, ''));
-                    }
-                    writer.writeLine(`  \${${schemaVariableName}}`);
-                    writer.write(`\``);
-                })
-                .formatText();
+        if (adminApiExtensions.getText().includes(`  \${${schemaVariableName}}`)) {
+            return;
         }
+        appendToGqlTemplateLiteral(adminApiExtensions, writer => {
+            writer.writeLine(`  \${${schemaVariableName}}`);
+        });
+    }
+}
+
+function appendToGqlTemplateLiteral(
+    variableDeclaration: VariableDeclaration,
+    append: (writer: CodeBlockWriter) => void,
+) {
+    const initializer = variableDeclaration.getInitializer();
+    if (Node.isTaggedTemplateExpression(initializer)) {
+        variableDeclaration
+            .setInitializer(writer => {
+                writer.write(`gql\``);
+                const template = initializer.getTemplate();
+                if (Node.isNoSubstitutionTemplateLiteral(template)) {
+                    writer.write(`${template.getLiteralValue()}`);
+                } else {
+                    writer.write(template.getText().replace(/^`/, '').replace(/`$/, ''));
+                }
+                append(writer);
+                writer.write(`\``);
+            })
+            .formatText();
     }
 }
 

+ 15 - 6
packages/cli/src/commands/add/api-extension/templates/simple-resolver.template.ts

@@ -1,8 +1,16 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import { Permission } from '@vendure/common/lib/generated-types';
-import { Allow, Ctx, RequestContext } from '@vendure/core';
+import { ID } from '@vendure/common/lib/shared-types';
+import { Allow, Ctx, RequestContext, Transaction } from '@vendure/core';
 
-class TemplateService {}
+class TemplateService {
+    async exampleQueryHandler(ctx: RequestContext, id: ID) {
+        return true;
+    }
+    async exampleMutationHandler(ctx: RequestContext, id: ID) {
+        return true;
+    }
+}
 
 @Resolver()
 export class SimpleAdminResolver {
@@ -10,13 +18,14 @@ export class SimpleAdminResolver {
 
     @Query()
     @Allow(Permission.SuperAdmin)
-    async exampleQuery(@Ctx() ctx: RequestContext, @Args() args: { id: string }): Promise<boolean> {
-        return true;
+    async exampleQuery(@Ctx() ctx: RequestContext, @Args() args: { id: ID }): Promise<boolean> {
+        return this.templateService.exampleQueryHandler(ctx, args.id);
     }
 
     @Mutation()
+    @Transaction()
     @Allow(Permission.SuperAdmin)
-    async exampleMutation(@Ctx() ctx: RequestContext, @Args() args: { id: string }): Promise<boolean> {
-        return true;
+    async exampleMutation(@Ctx() ctx: RequestContext, @Args() args: { id: ID }): Promise<boolean> {
+        return this.templateService.exampleMutationHandler(ctx, args.id);
     }
 }

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

@@ -32,7 +32,7 @@ export async function createNewPlugin(): Promise<CliCommandReturnVal> {
     if (!options.name) {
         const name = await text({
             message: 'What is the name of the plugin?',
-            initialValue: '',
+            initialValue: 'my-new-feature',
             validate: input => {
                 if (!/^[a-z][a-z-0-9]+$/.test(input)) {
                     return 'The plugin name must be lowercase and contain only letters, numbers and dashes';

+ 7 - 0
packages/cli/src/commands/add/plugin/templates/plugin.template.ts

@@ -6,6 +6,13 @@ import { PluginInitOptions } from './types.template';
 @VendurePlugin({
     imports: [PluginCommonModule],
     providers: [{ provide: TEMPLATE_PLUGIN_OPTIONS, useFactory: () => TemplatePlugin.options }],
+    configuration: config => {
+        // Plugin-specific configuration
+        // such as custom fields, custom permissions,
+        // strategies etc. can be configured here by
+        // modifying the `config` object.
+        return config;
+    },
     compatibility: '^2.0.0',
 })
 export class TemplatePlugin {

+ 1 - 1
packages/cli/src/commands/add/service/add-service.ts

@@ -65,7 +65,7 @@ async function addService(
         } 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 });
+                const result = await addEntityCommand.run({ plugin: vendurePlugin });
                 entityRef = result.entityRef;
                 modifiedSourceFiles.push(...result.modifiedSourceFiles);
             } else {

+ 3 - 3
packages/cli/src/commands/add/service/templates/basic-service.template.ts

@@ -1,13 +1,13 @@
 import { Injectable } from '@nestjs/common';
-import { Ctx, Product, RequestContext, TransactionalConnection } from '@vendure/core';
+import { ID, Product, RequestContext, TransactionalConnection } from '@vendure/core';
 
 @Injectable()
 export class BasicServiceTemplate {
     constructor(private connection: TransactionalConnection) {}
 
-    async exampleMethod(@Ctx() ctx: RequestContext) {
+    async exampleMethod(ctx: RequestContext, id: ID) {
         // Add your method logic here
-        const result = await this.connection.getRepository(ctx, Product).findOne({});
+        const result = await this.connection.getRepository(ctx, Product).findOne({ where: { id } });
         return result;
     }
 }

+ 2 - 1
packages/cli/src/constants.ts

@@ -8,6 +8,7 @@ export const pascalCaseRegex = /^[A-Z][a-zA-Z0-9]*$/;
 export const AdminUiExtensionTypeName = 'AdminUiExtension';
 export const AdminUiAppConfigName = 'AdminUiAppConfig';
 export const Messages = {
-    NoPluginsFound: `No plugins were found in this project. Create a plugin first by selecting "[Plugin] Add a new plugin"`,
+    NoPluginsFound: `No plugins were found in this project. Create a plugin first by selecting "[Plugin] Create a new Vendure plugin"`,
     NoEntitiesFound: `No entities were found in this plugin.`,
+    NoServicesFound: `No services were found in this plugin. Create a service first by selecting "[Plugin: Service] Add a new service to a plugin"`,
 };

+ 18 - 5
packages/cli/src/shared/shared-prompts.ts

@@ -112,21 +112,34 @@ export async function selectMultiplePluginClasses(
     return (targetPlugins as ClassDeclaration[]).map(pc => new VendurePluginRef(pc));
 }
 
-export async function selectServiceRef(project: Project, plugin: VendurePluginRef): Promise<ServiceRef> {
+export async function selectServiceRef(
+    project: Project,
+    plugin: VendurePluginRef,
+    canCreateNew = true,
+): Promise<ServiceRef> {
     const serviceRefs = getServices(project).filter(sr => {
         return sr.classDeclaration
             .getSourceFile()
             .getDirectoryPath()
             .includes(plugin.getSourceFile().getDirectoryPath());
     });
+
+    if (serviceRefs.length === 0 && !canCreateNew) {
+        throw new Error(Messages.NoServicesFound);
+    }
+
     const result = await select({
         message: 'Which service contains the business logic for this API extension?',
         maxItems: 8,
         options: [
-            {
-                value: 'new',
-                label: `Create new generic service`,
-            },
+            ...(canCreateNew
+                ? [
+                      {
+                          value: 'new',
+                          label: `Create new generic service`,
+                      },
+                  ]
+                : []),
             ...serviceRefs.map(sr => {
                 const features = sr.crudEntityRef
                     ? `CRUD service for ${sr.crudEntityRef.name}`

+ 5 - 2
packages/cli/src/utilities/ast-utils.ts

@@ -1,7 +1,7 @@
 import { log } from '@clack/prompts';
 import fs from 'fs-extra';
 import path from 'node:path';
-import { Directory, Node, Project, ProjectOptions, SourceFile } from 'ts-morph';
+import { Directory, Node, Project, ProjectOptions, ScriptKind, SourceFile } from 'ts-morph';
 
 import { defaultManipulationSettings } from '../constants';
 import { EntityRef } from '../shared/entity-ref';
@@ -11,7 +11,7 @@ export function getTsMorphProject(options: ProjectOptions = {}) {
     if (!fs.existsSync(tsConfigPath)) {
         throw new Error('No tsconfig.json found in current directory');
     }
-    return new Project({
+    const project = new Project({
         tsConfigFilePath: tsConfigPath,
         manipulationSettings: defaultManipulationSettings,
         skipFileDependencyResolution: true,
@@ -20,6 +20,8 @@ export function getTsMorphProject(options: ProjectOptions = {}) {
         },
         ...options,
     });
+    project.enableLogging(false);
+    return project;
 }
 
 export function getPluginClasses(project: Project) {
@@ -103,6 +105,7 @@ export function createFile(project: Project, templatePath: string) {
     try {
         return project.createSourceFile(path.join('/.vendure-cli-temp/', tempFilePath), template, {
             overwrite: true,
+            scriptKind: ScriptKind.TS,
         });
     } catch (e: any) {
         log.error(e.message);