Browse Source

refactor(cli): Use common CliCommand class

Michael Bromley 1 year ago
parent
commit
455698f418

+ 25 - 27
packages/cli/src/commands/add/add.ts

@@ -1,11 +1,13 @@
-import { cancel, isCancel, log, select } from '@clack/prompts';
+import { cancel, isCancel, log, outro, select } from '@clack/prompts';
 import { Command } from 'commander';
 import { Command } from 'commander';
 
 
-import { addCodegen } from './codegen/add-codegen';
-import { addEntity } from './entity/add-entity';
-import { createNewPlugin } from './plugin/create-new-plugin';
-import { addService } from './service/add-service';
-import { addUiExtensions } from './ui-extensions/add-ui-extensions';
+import { CliCommand } from '../../shared/cli-command';
+
+import { addCodegenCommand } from './codegen/add-codegen';
+import { addEntityCommand } from './entity/add-entity';
+import { createNewPluginCommand } from './plugin/create-new-plugin';
+import { addServiceCommand } from './service/add-service';
+import { addUiExtensionsCommand } from './ui-extensions/add-ui-extensions';
 
 
 const cancelledMessage = 'Add feature cancelled.';
 const cancelledMessage = 'Add feature cancelled.';
 
 
@@ -14,36 +16,32 @@ export function registerAddCommand(program: Command) {
         .command('add')
         .command('add')
         .description('Add a feature to your Vendure project')
         .description('Add a feature to your Vendure project')
         .action(async () => {
         .action(async () => {
+            const addCommands: Array<CliCommand<any, any>> = [
+                createNewPluginCommand,
+                addEntityCommand,
+                addServiceCommand,
+                addUiExtensionsCommand,
+                addCodegenCommand,
+            ];
             const featureType = await select({
             const featureType = await select({
                 message: 'Which feature would you like to add?',
                 message: 'Which feature would you like to add?',
-                options: [
-                    { value: 'plugin', label: '[Plugin] Add a new plugin' },
-                    { value: 'entity', label: '[Plugin: Entity] Add a new entity to a plugin' },
-                    { value: 'service', label: '[Plugin: Service] Add a new service to a plugin' },
-                    { value: 'uiExtensions', label: '[Plugin: UI] Set up Admin UI extensions' },
-                    { value: 'codegen', label: '[Project: Codegen] Set up GraphQL code generation' },
-                ],
+                options: addCommands.map(c => ({
+                    value: c.id,
+                    label: `[${c.category}] ${c.description}`,
+                })),
             });
             });
             if (isCancel(featureType)) {
             if (isCancel(featureType)) {
                 cancel(cancelledMessage);
                 cancel(cancelledMessage);
                 process.exit(0);
                 process.exit(0);
             }
             }
             try {
             try {
-                if (featureType === 'plugin') {
-                    await createNewPlugin();
-                }
-                if (featureType === 'uiExtensions') {
-                    await addUiExtensions();
-                }
-                if (featureType === 'entity') {
-                    await addEntity();
-                }
-                if (featureType === 'codegen') {
-                    await addCodegen();
-                }
-                if (featureType === 'service') {
-                    await addService();
+                const command = addCommands.find(c => c.id === featureType);
+                if (!command) {
+                    throw new Error(`Could not find command with id "${featureType as string}"`);
                 }
                 }
+                await command.run();
+
+                outro('✅ Done!');
             } catch (e: any) {
             } catch (e: any) {
                 log.error(e.message as string);
                 log.error(e.message as string);
                 if (e.stack) {
                 if (e.stack) {

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

@@ -2,6 +2,7 @@ import { log, note, outro, spinner } from '@clack/prompts';
 import path from 'path';
 import path from 'path';
 import { StructureKind } from 'ts-morph';
 import { StructureKind } from 'ts-morph';
 
 
+import { CliCommand } from '../../../shared/cli-command';
 import { PackageJson } from '../../../shared/package-json-ref';
 import { PackageJson } from '../../../shared/package-json-ref';
 import { analyzeProject, selectMultiplePluginClasses } from '../../../shared/shared-prompts';
 import { analyzeProject, selectMultiplePluginClasses } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
@@ -9,7 +10,19 @@ import { getRelativeImportPath } from '../../../utilities/ast-utils';
 
 
 import { CodegenConfigRef } from './codegen-config-ref';
 import { CodegenConfigRef } from './codegen-config-ref';
 
 
-export async function addCodegen(providedVendurePlugin?: VendurePluginRef) {
+export interface AddCodegenOptions {
+    plugin?: VendurePluginRef;
+}
+
+export const addCodegenCommand = new CliCommand({
+    id: 'add-codegen',
+    category: 'Project: Codegen',
+    description: 'Set up GraphQL code generation',
+    run: addCodegen,
+});
+
+async function addCodegen(options?: AddCodegenOptions) {
+    const providedVendurePlugin = options?.plugin;
     const project = await analyzeProject({
     const project = await analyzeProject({
         providedVendurePlugin,
         providedVendurePlugin,
         cancelledMessage: 'Add codegen cancelled',
         cancelledMessage: 'Add codegen cancelled',

+ 53 - 35
packages/cli/src/commands/add/entity/add-entity.ts

@@ -4,6 +4,8 @@ import path from 'path';
 import { ClassDeclaration } from 'ts-morph';
 import { ClassDeclaration } from 'ts-morph';
 
 
 import { pascalCaseRegex } from '../../../constants';
 import { pascalCaseRegex } from '../../../constants';
+import { CliCommand } from '../../../shared/cli-command';
+import { EntityRef } from '../../../shared/entity-ref';
 import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
 import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { createFile } from '../../../utilities/ast-utils';
 import { createFile } from '../../../utilities/ast-utils';
@@ -12,7 +14,8 @@ import { addEntityToPlugin } from './codemods/add-entity-to-plugin/add-entity-to
 
 
 const cancelledMessage = 'Add entity cancelled';
 const cancelledMessage = 'Add entity cancelled';
 
 
-export interface AddEntityTemplateContext {
+export interface AddEntityOptions {
+    plugin?: VendurePluginRef;
     className: string;
     className: string;
     fileName: string;
     fileName: string;
     translationFileName: string;
     translationFileName: string;
@@ -22,12 +25,47 @@ export interface AddEntityTemplateContext {
     };
     };
 }
 }
 
 
-export async function addEntity(providedVendurePlugin?: VendurePluginRef) {
+export const addEntityCommand = new CliCommand({
+    id: 'add-entity',
+    category: 'Plugin: Entity',
+    description: 'Add a new entity to a plugin',
+    run: options => addEntity(options),
+});
+
+async function addEntity(options?: Partial<AddEntityOptions>) {
+    const providedVendurePlugin = options?.plugin;
     const project = await analyzeProject({ providedVendurePlugin, cancelledMessage });
     const project = await analyzeProject({ providedVendurePlugin, cancelledMessage });
     const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
     const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
 
 
-    const customEntityName = await getCustomEntityName(cancelledMessage);
+    const customEntityName = options?.className ?? (await getCustomEntityName(cancelledMessage));
+
+    const context: AddEntityOptions = {
+        className: customEntityName,
+        fileName: paramCase(customEntityName) + '.entity',
+        translationFileName: paramCase(customEntityName) + '-translation.entity',
+        features: await getFeatures(options),
+    };
+
+    const { entityClass, translationClass } = createEntity(vendurePlugin, context);
+    addEntityToPlugin(vendurePlugin, entityClass);
+    entityClass.getSourceFile().organizeImports();
+    if (context.features.translatable) {
+        addEntityToPlugin(vendurePlugin, translationClass);
+        translationClass.getSourceFile().organizeImports();
+    }
+
+    await project.save();
+
+    if (!providedVendurePlugin) {
+        outro('✅  Done!');
+    }
+    return new EntityRef(entityClass);
+}
 
 
+async function getFeatures(options?: Partial<AddEntityOptions>): Promise<AddEntityOptions['features']> {
+    if (options?.features) {
+        return options?.features;
+    }
     const features = await multiselect({
     const features = await multiselect({
         message: 'Entity features (use ↑, ↓, space to select)',
         message: 'Entity features (use ↑, ↓, space to select)',
         required: false,
         required: false,
@@ -49,33 +87,13 @@ export async function addEntity(providedVendurePlugin?: VendurePluginRef) {
         cancel(cancelledMessage);
         cancel(cancelledMessage);
         process.exit(0);
         process.exit(0);
     }
     }
-
-    const context: AddEntityTemplateContext = {
-        className: customEntityName,
-        fileName: paramCase(customEntityName) + '.entity',
-        translationFileName: paramCase(customEntityName) + '-translation.entity',
-        features: {
-            customFields: features.includes('customFields'),
-            translatable: features.includes('translatable'),
-        },
+    return {
+        customFields: features.includes('customFields'),
+        translatable: features.includes('translatable'),
     };
     };
-
-    const { entityClass, translationClass } = createEntity(vendurePlugin, context);
-    addEntityToPlugin(vendurePlugin, entityClass);
-    entityClass.getSourceFile().organizeImports();
-    if (context.features.translatable) {
-        addEntityToPlugin(vendurePlugin, translationClass);
-        translationClass.getSourceFile().organizeImports();
-    }
-
-    await project.save();
-
-    if (!providedVendurePlugin) {
-        outro('✅  Done!');
-    }
 }
 }
 
 
-function createEntity(plugin: VendurePluginRef, context: AddEntityTemplateContext) {
+function createEntity(plugin: VendurePluginRef, options: AddEntityOptions) {
     const entitiesDir = path.join(plugin.getPluginDir().getPath(), 'entities');
     const entitiesDir = path.join(plugin.getPluginDir().getPath(), 'entities');
     const entityFile = createFile(
     const entityFile = createFile(
         plugin.getSourceFile().getProject(),
         plugin.getSourceFile().getProject(),
@@ -85,28 +103,28 @@ function createEntity(plugin: VendurePluginRef, context: AddEntityTemplateContex
         plugin.getSourceFile().getProject(),
         plugin.getSourceFile().getProject(),
         path.join(__dirname, 'templates/entity-translation.template.ts'),
         path.join(__dirname, 'templates/entity-translation.template.ts'),
     );
     );
-    entityFile.move(path.join(entitiesDir, `${context.fileName}.ts`));
-    translationFile.move(path.join(entitiesDir, `${context.translationFileName}.ts`));
+    entityFile.move(path.join(entitiesDir, `${options.fileName}.ts`));
+    translationFile.move(path.join(entitiesDir, `${options.translationFileName}.ts`));
 
 
-    const entityClass = entityFile.getClass('ScaffoldEntity')?.rename(context.className);
+    const entityClass = entityFile.getClass('ScaffoldEntity')?.rename(options.className);
     const customFieldsClass = entityFile
     const customFieldsClass = entityFile
         .getClass('ScaffoldEntityCustomFields')
         .getClass('ScaffoldEntityCustomFields')
-        ?.rename(`${context.className}CustomFields`);
+        ?.rename(`${options.className}CustomFields`);
     const translationClass = translationFile
     const translationClass = translationFile
         .getClass('ScaffoldTranslation')
         .getClass('ScaffoldTranslation')
-        ?.rename(`${context.className}Translation`);
+        ?.rename(`${options.className}Translation`);
     const translationCustomFieldsClass = translationFile
     const translationCustomFieldsClass = translationFile
         .getClass('ScaffoldEntityCustomFieldsTranslation')
         .getClass('ScaffoldEntityCustomFieldsTranslation')
-        ?.rename(`${context.className}CustomFieldsTranslation`);
+        ?.rename(`${options.className}CustomFieldsTranslation`);
 
 
-    if (!context.features.customFields) {
+    if (!options.features.customFields) {
         // Remove custom fields from entity
         // Remove custom fields from entity
         customFieldsClass?.remove();
         customFieldsClass?.remove();
         translationCustomFieldsClass?.remove();
         translationCustomFieldsClass?.remove();
         removeCustomFieldsFromClass(entityClass);
         removeCustomFieldsFromClass(entityClass);
         removeCustomFieldsFromClass(translationClass);
         removeCustomFieldsFromClass(translationClass);
     }
     }
-    if (!context.features.translatable) {
+    if (!options.features.translatable) {
         // Remove translatable fields from entity
         // Remove translatable fields from entity
         translationClass?.remove();
         translationClass?.remove();
         entityClass?.getProperty('localizedName')?.remove();
         entityClass?.getProperty('localizedName')?.remove();

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

@@ -3,16 +3,24 @@ import { constantCase, paramCase, pascalCase } from 'change-case';
 import * as fs from 'fs-extra';
 import * as fs from 'fs-extra';
 import path from 'path';
 import path from 'path';
 
 
+import { CliCommand } from '../../../shared/cli-command';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { addImportsToFile, createFile, getTsMorphProject } from '../../../utilities/ast-utils';
 import { addImportsToFile, createFile, getTsMorphProject } from '../../../utilities/ast-utils';
-import { addCodegen } from '../codegen/add-codegen';
-import { addEntity } from '../entity/add-entity';
-import { addService } from '../service/add-service';
-import { addUiExtensions } from '../ui-extensions/add-ui-extensions';
+import { addCodegenCommand } from '../codegen/add-codegen';
+import { addEntityCommand } from '../entity/add-entity';
+import { addServiceCommand } from '../service/add-service';
+import { addUiExtensionsCommand } from '../ui-extensions/add-ui-extensions';
 
 
 import { GeneratePluginOptions, NewPluginTemplateContext } from './types';
 import { GeneratePluginOptions, NewPluginTemplateContext } from './types';
 
 
+export const createNewPluginCommand = new CliCommand({
+    id: 'create-new-plugin',
+    category: 'Plugin',
+    description: 'Create a new Vendure plugin',
+    run: createNewPlugin,
+});
+
 const cancelledMessage = 'Plugin setup cancelled.';
 const cancelledMessage = 'Plugin setup cancelled.';
 
 
 export async function createNewPlugin() {
 export async function createNewPlugin() {
@@ -69,18 +77,16 @@ export async function createNewPlugin() {
     configSpinner.stop('Updated VendureConfig');
     configSpinner.stop('Updated VendureConfig');
 
 
     let done = false;
     let done = false;
+    const followUpCommands = [addEntityCommand, addServiceCommand, addUiExtensionsCommand, addCodegenCommand];
     while (!done) {
     while (!done) {
         const featureType = await select({
         const featureType = await select({
             message: `Add features to ${options.name}?`,
             message: `Add features to ${options.name}?`,
             options: [
             options: [
                 { value: 'no', label: "[Finish] No, I'm done!" },
                 { value: 'no', label: "[Finish] No, I'm done!" },
-                { value: 'entity', label: '[Plugin: Entity] Add a new entity to the plugin' },
-                { value: 'service', label: '[Plugin: Service] Add a new service to the plugin' },
-                { value: 'uiExtensions', label: '[Plugin: UI] Set up Admin UI extensions' },
-                {
-                    value: 'codegen',
-                    label: '[Plugin: Codegen] Set up GraphQL code generation for this plugin',
-                },
+                ...followUpCommands.map(c => ({
+                    value: c.id,
+                    label: `[${c.category}] ${c.description}`,
+                })),
             ],
             ],
         });
         });
         if (isCancel(featureType)) {
         if (isCancel(featureType)) {
@@ -88,18 +94,11 @@ export async function createNewPlugin() {
         }
         }
         if (featureType === 'no') {
         if (featureType === 'no') {
             done = true;
             done = true;
-        } else if (featureType === 'entity') {
-            await addEntity(plugin);
-        } else if (featureType === 'uiExtensions') {
-            await addUiExtensions(plugin);
-        } else if (featureType === 'codegen') {
-            await addCodegen(plugin);
-        } else if (featureType === 'service') {
-            await addService(plugin);
+        } else {
+            const command = followUpCommands.find(c => c.id === featureType);
+            await command?.run({ plugin });
         }
         }
     }
     }
-
-    outro('✅ Plugin setup complete!');
 }
 }
 
 
 export async function generatePlugin(options: GeneratePluginOptions): Promise<VendurePluginRef> {
 export async function generatePlugin(options: GeneratePluginOptions): Promise<VendurePluginRef> {

+ 43 - 27
packages/cli/src/commands/add/service/add-service.ts

@@ -3,47 +3,61 @@ import path from 'path';
 import { ClassDeclaration, SourceFile } from 'ts-morph';
 import { ClassDeclaration, SourceFile } from 'ts-morph';
 
 
 import { pascalCaseRegex } from '../../../constants';
 import { pascalCaseRegex } from '../../../constants';
+import { CliCommand } from '../../../shared/cli-command';
 import { EntityRef } from '../../../shared/entity-ref';
 import { EntityRef } from '../../../shared/entity-ref';
+import { ServiceRef } from '../../../shared/service-ref';
 import { analyzeProject, selectEntity, selectPlugin } from '../../../shared/shared-prompts';
 import { analyzeProject, selectEntity, selectPlugin } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { addImportsToFile, createFile, kebabize } from '../../../utilities/ast-utils';
 import { addImportsToFile, createFile, kebabize } from '../../../utilities/ast-utils';
 
 
 const cancelledMessage = 'Add service cancelled';
 const cancelledMessage = 'Add service cancelled';
 
 
-interface AddServiceTemplateContext {
+interface AddServiceOptions {
+    plugin?: VendurePluginRef;
     type: 'basic' | 'entity';
     type: 'basic' | 'entity';
     serviceName: string;
     serviceName: string;
     entityRef?: EntityRef;
     entityRef?: EntityRef;
 }
 }
 
 
-export async function addService(providedVendurePlugin?: VendurePluginRef) {
+export const addServiceCommand = new CliCommand<AddServiceOptions, ServiceRef>({
+    id: 'add-service',
+    category: 'Plugin: Service',
+    description: 'Add a new service to a plugin',
+    run: options => addService(options),
+});
+
+async function addService(providedOptions?: Partial<AddServiceOptions>) {
+    const providedVendurePlugin = providedOptions?.plugin;
     const project = await analyzeProject({ providedVendurePlugin, cancelledMessage });
     const project = await analyzeProject({ providedVendurePlugin, cancelledMessage });
     const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
     const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
 
 
-    const type = await select({
-        message: 'What type of service would you like to add?',
-        options: [
-            { value: 'basic', label: 'Basic empty service' },
-            { value: 'entity', label: 'Service to perform CRUD operations on an entity' },
-        ],
-        maxItems: 10,
-    });
+    const type =
+        providedOptions?.type ??
+        (await select({
+            message: 'What type of service would you like to add?',
+            options: [
+                { value: 'basic', label: 'Basic empty service' },
+                { value: 'entity', label: 'Service to perform CRUD operations on an entity' },
+            ],
+            maxItems: 10,
+        }));
     if (isCancel(type)) {
     if (isCancel(type)) {
         cancel('Cancelled');
         cancel('Cancelled');
         process.exit(0);
         process.exit(0);
     }
     }
-    const context: AddServiceTemplateContext = {
-        type: type as AddServiceTemplateContext['type'],
+    const options: AddServiceOptions = {
+        type: type as AddServiceOptions['type'],
         serviceName: 'MyService',
         serviceName: 'MyService',
     };
     };
     if (type === 'entity') {
     if (type === 'entity') {
         const entityRef = await selectEntity(vendurePlugin);
         const entityRef = await selectEntity(vendurePlugin);
-        context.entityRef = entityRef;
-        context.serviceName = `${entityRef.name}Service`;
+        options.entityRef = entityRef;
+        options.serviceName = `${entityRef.name}Service`;
     }
     }
 
 
     let serviceSourceFile: SourceFile;
     let serviceSourceFile: SourceFile;
-    if (context.type === 'basic') {
+    let serviceClassDeclaration: ClassDeclaration;
+    if (options.type === 'basic') {
         serviceSourceFile = createFile(project, path.join(__dirname, 'templates/basic-service.template.ts'));
         serviceSourceFile = createFile(project, path.join(__dirname, 'templates/basic-service.template.ts'));
         const name = await text({
         const name = await text({
             message: 'What is the name of the new service?',
             message: 'What is the name of the new service?',
@@ -61,17 +75,18 @@ export async function addService(providedVendurePlugin?: VendurePluginRef) {
             cancel(cancelledMessage);
             cancel(cancelledMessage);
             process.exit(0);
             process.exit(0);
         }
         }
-        context.serviceName = name;
-        serviceSourceFile.getClass('BasicServiceTemplate')?.rename(context.serviceName);
+        options.serviceName = name;
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        serviceClassDeclaration = serviceSourceFile
+            .getClass('BasicServiceTemplate')!
+            .rename(options.serviceName);
     } else {
     } else {
         serviceSourceFile = createFile(project, path.join(__dirname, 'templates/entity-service.template.ts'));
         serviceSourceFile = createFile(project, path.join(__dirname, 'templates/entity-service.template.ts'));
-        const serviceClassDeclaration = serviceSourceFile
-            .getClass('EntityServiceTemplate')
-            ?.rename(context.serviceName);
-        if (!serviceClassDeclaration) {
-            throw new Error('Could not find service class declaration');
-        }
-        const entityRef = context.entityRef;
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        serviceClassDeclaration = serviceSourceFile
+            .getClass('EntityServiceTemplate')!
+            .rename(options.serviceName);
+        const entityRef = options.entityRef;
         if (!entityRef) {
         if (!entityRef) {
             throw new Error('Entity class not found');
             throw new Error('Entity class not found');
         }
         }
@@ -107,15 +122,15 @@ export async function addService(providedVendurePlugin?: VendurePluginRef) {
         removedUnusedConstructorArgs(serviceClassDeclaration, entityRef);
         removedUnusedConstructorArgs(serviceClassDeclaration, entityRef);
     }
     }
 
 
-    const serviceFileName = kebabize(context.serviceName).replace(/-service$/, '.service');
+    const serviceFileName = kebabize(options.serviceName).replace(/-service$/, '.service');
     serviceSourceFile?.move(
     serviceSourceFile?.move(
         path.join(vendurePlugin.getPluginDir().getPath(), 'services', `${serviceFileName}.ts`),
         path.join(vendurePlugin.getPluginDir().getPath(), 'services', `${serviceFileName}.ts`),
     );
     );
 
 
-    vendurePlugin.addProvider(context.serviceName);
+    vendurePlugin.addProvider(options.serviceName);
     addImportsToFile(vendurePlugin.classDeclaration.getSourceFile(), {
     addImportsToFile(vendurePlugin.classDeclaration.getSourceFile(), {
         moduleSpecifier: serviceSourceFile,
         moduleSpecifier: serviceSourceFile,
-        namedImports: [context.serviceName],
+        namedImports: [options.serviceName],
     });
     });
 
 
     serviceSourceFile.organizeImports();
     serviceSourceFile.organizeImports();
@@ -124,6 +139,7 @@ export async function addService(providedVendurePlugin?: VendurePluginRef) {
     if (!providedVendurePlugin) {
     if (!providedVendurePlugin) {
         outro('✅  Done!');
         outro('✅  Done!');
     }
     }
+    return new ServiceRef(serviceClassDeclaration);
 }
 }
 
 
 function customizeFindOneMethod(serviceClassDeclaration: ClassDeclaration, entityRef: EntityRef) {
 function customizeFindOneMethod(serviceClassDeclaration: ClassDeclaration, entityRef: EntityRef) {

+ 14 - 1
packages/cli/src/commands/add/ui-extensions/add-ui-extensions.ts

@@ -1,6 +1,7 @@
 import { log, note, outro, spinner } from '@clack/prompts';
 import { log, note, outro, spinner } from '@clack/prompts';
 import path from 'path';
 import path from 'path';
 
 
+import { CliCommand } from '../../../shared/cli-command';
 import { PackageJson } from '../../../shared/package-json-ref';
 import { PackageJson } from '../../../shared/package-json-ref';
 import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
 import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
@@ -10,7 +11,19 @@ import { createFile, getRelativeImportPath } from '../../../utilities/ast-utils'
 import { addUiExtensionStaticProp } from './codemods/add-ui-extension-static-prop/add-ui-extension-static-prop';
 import { addUiExtensionStaticProp } from './codemods/add-ui-extension-static-prop/add-ui-extension-static-prop';
 import { updateAdminUiPluginInit } from './codemods/update-admin-ui-plugin-init/update-admin-ui-plugin-init';
 import { updateAdminUiPluginInit } from './codemods/update-admin-ui-plugin-init/update-admin-ui-plugin-init';
 
 
-export async function addUiExtensions(providedVendurePlugin?: VendurePluginRef) {
+export interface AddUiExtensionsOptions {
+    plugin?: VendurePluginRef;
+}
+
+export const addUiExtensionsCommand = new CliCommand<AddUiExtensionsOptions>({
+    id: 'add-ui-extensions',
+    category: 'Plugin: UI',
+    description: 'Set up Admin UI extensions',
+    run: options => addUiExtensions(options),
+});
+
+async function addUiExtensions(options?: AddUiExtensionsOptions) {
+    const providedVendurePlugin = options?.plugin;
     const project = await analyzeProject({ providedVendurePlugin });
     const project = await analyzeProject({ providedVendurePlugin });
     const vendurePlugin =
     const vendurePlugin =
         providedVendurePlugin ?? (await selectPlugin(project, 'Add UI extensions cancelled'));
         providedVendurePlugin ?? (await selectPlugin(project, 'Add UI extensions cancelled'));

+ 41 - 0
packages/cli/src/shared/cli-command.ts

@@ -0,0 +1,41 @@
+import { VendurePluginRef } from './vendure-plugin-ref';
+
+export type CommandCategory =
+    | `Plugin`
+    | `Plugin: UI`
+    | `Plugin: Entity`
+    | `Plugin: Service`
+    | `Plugin: API`
+    | `Project: Codegen`
+    | `Other`;
+
+export interface BaseCliCommandOptions {
+    plugin?: VendurePluginRef;
+}
+
+export interface CliCommandOptions<T extends BaseCliCommandOptions, R> {
+    id: string;
+    category: CommandCategory;
+    description: string;
+    run: (options?: Partial<T>) => Promise<R>;
+}
+
+export class CliCommand<T extends Record<string, any>, R = void> {
+    constructor(private options: CliCommandOptions<T, R>) {}
+
+    get id() {
+        return this.options.id;
+    }
+
+    get category() {
+        return this.options.category;
+    }
+
+    get description() {
+        return this.options.description;
+    }
+
+    run(options?: Partial<T>) {
+        return this.options.run(options);
+    }
+}

+ 75 - 0
packages/cli/src/shared/service-ref.ts

@@ -0,0 +1,75 @@
+import { ClassDeclaration, Node, Type } from 'ts-morph';
+
+import { EntityRef } from './entity-ref';
+
+export interface ServiceFeatures {
+    findOne: boolean;
+    findAll: boolean;
+    create: boolean;
+    update: boolean;
+    delete: boolean;
+}
+
+export class ServiceRef {
+    readonly features: ServiceFeatures;
+    readonly crudEntityRef?: EntityRef;
+
+    get name(): string {
+        return this.classDeclaration.getName() as string;
+    }
+
+    get isCrudService(): boolean {
+        return this.crudEntityRef !== undefined;
+    }
+
+    constructor(public readonly classDeclaration: ClassDeclaration) {
+        this.features = {
+            findOne: !!this.classDeclaration.getMethod('findOne'),
+            findAll: !!this.classDeclaration.getMethod('findAll'),
+            create: !!this.classDeclaration.getMethod('create'),
+            update: !!this.classDeclaration.getMethod('update'),
+            delete: !!this.classDeclaration.getMethod('delete'),
+        };
+        this.crudEntityRef = this.getEntityRef();
+    }
+
+    private getEntityRef(): EntityRef | undefined {
+        if (this.features.findOne) {
+            const potentialCrudMethodNames = ['findOne', 'findAll', 'create', 'update', 'delete'];
+            for (const methodName of potentialCrudMethodNames) {
+                const findOneMethod = this.classDeclaration.getMethod(methodName);
+                const returnType = findOneMethod?.getReturnType();
+                if (returnType) {
+                    const unwrappedReturnType = this.unwrapReturnType(returnType);
+                    const typeDeclaration = unwrappedReturnType.getSymbolOrThrow().getDeclarations()[0];
+                    if (typeDeclaration && Node.isClassDeclaration(typeDeclaration)) {
+                        if (typeDeclaration.getExtends()?.getText() === 'VendureEntity') {
+                            return new EntityRef(typeDeclaration);
+                        }
+                    }
+                }
+            }
+        }
+        return;
+    }
+
+    private unwrapReturnType(returnType: Type): Type {
+        if (returnType.isUnion()) {
+            // get the non-null part of the union
+            const nonNullType = returnType.getUnionTypes().find(t => !t.isNull() && !t.isUndefined());
+            if (!nonNullType) {
+                throw new Error('Could not find non-null type in union');
+            }
+            return this.unwrapReturnType(nonNullType);
+        }
+        const typeArguments = returnType.getTypeArguments();
+        if (typeArguments.length) {
+            return this.unwrapReturnType(typeArguments[0]);
+        }
+        const aliasTypeArguments = returnType.getAliasTypeArguments();
+        if (aliasTypeArguments.length) {
+            return this.unwrapReturnType(aliasTypeArguments[0]);
+        }
+        return returnType;
+    }
+}