Просмотр исходного кода

feat(cli): Add non-interactive modes to the add commands - needs linting and is WIP

HouseinIsProgramming 7 месяцев назад
Родитель
Сommit
74acf6e492

+ 35 - 13
packages/cli/src/commands/add/add-operations.ts

@@ -16,14 +16,16 @@ export interface AddOperationOptions {
     entity?: string;
     /** Add a new service with the given name */
     service?: string;
-    /** Add a job-queue handler (boolean flag) */
-    jobQueue?: boolean;
-    /** Add GraphQL codegen configuration (boolean flag) */
-    codegen?: boolean;
-    /** Add an API extension scaffold (boolean flag) */
-    apiExtension?: boolean;
-    /** Add Admin-UI or Storefront UI extensions (boolean flag) */
-    uiExtensions?: boolean;
+    /** Add a job-queue handler to the specified plugin */
+    jobQueue?: string | boolean;
+    /** Add GraphQL codegen configuration to the specified plugin */
+    codegen?: string | boolean;
+    /** Add an API extension scaffold to the specified plugin */
+    apiExtension?: string | boolean;
+    /** Add Admin-UI or Storefront UI extensions to the specified plugin */
+    uiExtensions?: string | boolean;
+    /** Specify the path to a custom Vendure config file */
+    config?: string;
 }
 
 export interface AddOperationResult {
@@ -42,7 +44,7 @@ export async function performAddOperation(options: AddOperationOptions): Promise
         // Figure out which flag was set. They are mutually exclusive: the first
         // truthy option determines the sub-command we run.
         if (options.plugin) {
-            await createNewPluginCommand.run({ name: options.plugin });
+            await createNewPluginCommand.run({ name: options.plugin, config: options.config });
             return {
                 success: true,
                 message: `Plugin \"${options.plugin}\" created successfully`,
@@ -64,28 +66,48 @@ export async function performAddOperation(options: AddOperationOptions): Promise
             };
         }
         if (options.jobQueue) {
-            await addJobQueueCommand.run();
+            const pluginName = typeof options.jobQueue === 'string' ? options.jobQueue : undefined;
+            await addJobQueueCommand.run({ 
+                isNonInteractive: true, 
+                config: options.config,
+                pluginName 
+            });
             return {
                 success: true,
                 message: 'Job-queue feature added successfully',
             };
         }
         if (options.codegen) {
-            await addCodegenCommand.run();
+            const pluginName = typeof options.codegen === 'string' ? options.codegen : undefined;
+            await addCodegenCommand.run({ 
+                isNonInteractive: true, 
+                config: options.config,
+                pluginName 
+            });
             return {
                 success: true,
                 message: 'Codegen configuration added successfully',
             };
         }
         if (options.apiExtension) {
-            await addApiExtensionCommand.run();
+            const pluginName = typeof options.apiExtension === 'string' ? options.apiExtension : undefined;
+            await addApiExtensionCommand.run({ 
+                isNonInteractive: true, 
+                config: options.config,
+                pluginName 
+            });
             return {
                 success: true,
                 message: 'API extension scaffold added successfully',
             };
         }
         if (options.uiExtensions) {
-            await addUiExtensionsCommand.run();
+            const pluginName = typeof options.uiExtensions === 'string' ? options.uiExtensions : undefined;
+            await addUiExtensionsCommand.run({ 
+                isNonInteractive: true, 
+                config: options.config,
+                pluginName 
+            });
             return {
                 success: true,
                 message: 'UI extensions added successfully',

+ 103 - 18
packages/cli/src/commands/add/api-extension/add-api-extension.ts

@@ -17,19 +17,27 @@ import {
 import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { EntityRef } from '../../../shared/entity-ref';
 import { ServiceRef } from '../../../shared/service-ref';
-import { analyzeProject, selectPlugin, selectServiceRef } from '../../../shared/shared-prompts';
+import { analyzeProject, selectPlugin, selectServiceRef, getServices } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import {
     addImportsToFile,
     createFile,
     customizeCreateUpdateInputInterfaces,
+    getRelativeImportPath,
+    getPluginClasses,
 } from '../../../utilities/ast-utils';
 import { pauseForPromptDisplay } from '../../../utilities/utils';
+import { addServiceCommand } from '../service/add-service';
 
 const cancelledMessage = 'Add API extension cancelled';
 
 export interface AddApiExtensionOptions {
     plugin?: VendurePluginRef;
+    pluginName?: string;
+    queryName?: string;
+    mutationName?: string;
+    config?: string;
+    isNonInteractive?: boolean;
 }
 
 export const addApiExtensionCommand = new CliCommand({
@@ -43,9 +51,80 @@ async function addApiExtension(
     options?: AddApiExtensionOptions,
 ): Promise<CliCommandReturnVal<{ serviceRef: ServiceRef }>> {
     const providedVendurePlugin = options?.plugin;
-    const { project } = await analyzeProject({ providedVendurePlugin, cancelledMessage });
-    const plugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
-    const serviceRef = await selectServiceRef(project, plugin, false);
+    const { project } = await analyzeProject({ providedVendurePlugin, cancelledMessage, config: options?.config });
+
+    // Detect non-interactive mode
+    const isNonInteractive = options?.isNonInteractive === true;
+    
+    let plugin: VendurePluginRef | undefined = providedVendurePlugin;
+    
+    // If a plugin name was provided, try to find it
+    if (!plugin && options?.pluginName) {
+        const pluginClasses = getPluginClasses(project);
+        const foundPlugin = pluginClasses.find(p => p.getName() === options.pluginName);
+        
+        if (!foundPlugin) {
+            // List available plugins if the specified one wasn't found
+            const availablePlugins = pluginClasses.map(p => p.getName()).filter(Boolean);
+            throw new Error(
+                `Plugin "${options.pluginName}" not found. Available plugins:\n` +
+                availablePlugins.map(name => `  - ${name}`).join('\n')
+            );
+        }
+        
+        plugin = new VendurePluginRef(foundPlugin);
+    }
+    
+    // In non-interactive mode, we need all required values
+    if (isNonInteractive) {
+        if (!plugin) {
+            throw new Error('Plugin must be specified when running in non-interactive mode');
+        }
+        // Don't require query/mutation names - we'll use defaults
+    }
+
+    plugin = plugin ?? (await selectPlugin(project, cancelledMessage));
+    
+    // In non-interactive mode, we cannot prompt for service selection
+    if (isNonInteractive && !plugin) {
+        throw new Error('Cannot select service in non-interactive mode - plugin must be specified');
+    }
+    
+    let serviceRef: ServiceRef | undefined;
+    
+    if (isNonInteractive) {
+        // In non-interactive mode, find existing services or create a new one
+        const existingServices = getServices(project).filter(sr => {
+            return sr.classDeclaration
+                .getSourceFile()
+                .getDirectoryPath()
+                .includes(plugin.getSourceFile().getDirectoryPath());
+        });
+        
+        if (existingServices.length > 0) {
+            // Use the first available service
+            serviceRef = existingServices[0];
+            log.info(`Using existing service: ${serviceRef.name}`);
+        } else {
+            // Create a new service automatically
+            log.info('No existing services found, creating a new service...');
+            const result = await addServiceCommand.run({ 
+                type: 'basic', 
+                plugin,
+                serviceName: 'GeneratedService',
+                isNonInteractive: true
+            });
+            serviceRef = result.serviceRef;
+        }
+    } else {
+        // Interactive mode - let user choose
+        serviceRef = await selectServiceRef(project, plugin, false);
+    }
+
+    if (!serviceRef) {
+        throw new Error('Service is required for API extension');
+    }
+
     const serviceEntityRef = serviceRef.crudEntityRef;
     const modifiedSourceFiles: SourceFile[] = [];
     let resolver: ClassDeclaration | undefined;
@@ -56,19 +135,25 @@ async function addApiExtension(
     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;
+        if (isNonInteractive) {
+            // Use defaults in non-interactive mode
+            queryName = options?.queryName || 'customQuery';
+            mutationName = options?.mutationName || 'customMutation';
+        } else {
+            const queryNameResult = options?.queryName ?? await text({
+                message: 'Enter a name for the new query',
+                initialValue: 'myNewQuery',
+            });
+            if (!isCancel(queryNameResult)) {
+                queryName = queryNameResult;
+            }
+            const mutationNameResult = options?.mutationName ?? await text({
+                message: 'Enter a name for the new mutation',
+                initialValue: 'myNewMutation',
+            });
+            if (!isCancel(mutationNameResult)) {
+                mutationName = mutationNameResult;
+            }
         }
     }
 
@@ -78,7 +163,7 @@ async function addApiExtension(
         resolver = createCrudResolver(project, plugin, serviceRef, serviceEntityRef);
         modifiedSourceFiles.push(resolver.getSourceFile());
     } else {
-        if (isCancel(queryName)) {
+        if (!isNonInteractive && isCancel(queryName)) {
             cancel(cancelledMessage);
             process.exit(0);
         }

+ 35 - 3
packages/cli/src/commands/add/codegen/add-codegen.ts

@@ -6,13 +6,16 @@ import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 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 { getRelativeImportPath, getPluginClasses } from '../../../utilities/ast-utils';
 import { pauseForPromptDisplay } from '../../../utilities/utils';
 
 import { CodegenConfigRef } from './codegen-config-ref';
 
 export interface AddCodegenOptions {
     plugin?: VendurePluginRef;
+    pluginName?: string;
+    config?: string;
+    isNonInteractive?: boolean;
 }
 
 export const addCodegenCommand = new CliCommand({
@@ -27,9 +30,38 @@ async function addCodegen(options?: AddCodegenOptions): Promise<CliCommandReturn
     const { project } = await analyzeProject({
         providedVendurePlugin,
         cancelledMessage: 'Add codegen cancelled',
+        config: options?.config,
     });
-    const plugins = providedVendurePlugin
-        ? [providedVendurePlugin]
+
+    // Detect non-interactive mode
+    const isNonInteractive = options?.isNonInteractive === true;
+    
+    let plugin: VendurePluginRef | undefined = providedVendurePlugin;
+    
+    // If a plugin name was provided, try to find it
+    if (!plugin && options?.pluginName) {
+        const pluginClasses = getPluginClasses(project);
+        const foundPlugin = pluginClasses.find(p => p.getName() === options.pluginName);
+        
+        if (!foundPlugin) {
+            // List available plugins if the specified one wasn't found
+            const availablePlugins = pluginClasses.map(p => p.getName()).filter(Boolean);
+            throw new Error(
+                `Plugin "${options.pluginName}" not found. Available plugins:\n` +
+                availablePlugins.map(name => `  - ${name}`).join('\n')
+            );
+        }
+        
+        plugin = new VendurePluginRef(foundPlugin);
+    }
+    
+    // In non-interactive mode, we need a plugin specified
+    if (isNonInteractive && !plugin) {
+        throw new Error('Plugin must be specified when running in non-interactive mode');
+    }
+
+    const plugins = plugin
+        ? [plugin]
         : await selectMultiplePluginClasses(project, 'Add codegen cancelled');
 
     const packageJson = new PackageJson(project);

+ 17 - 1
packages/cli/src/commands/add/entity/add-entity.ts

@@ -24,6 +24,8 @@ export interface AddEntityOptions {
         customFields: boolean;
         translatable: boolean;
     };
+    config?: string;
+    isNonInteractive?: boolean;
 }
 
 export const addEntityCommand = new CliCommand({
@@ -37,7 +39,13 @@ async function addEntity(
     options?: Partial<AddEntityOptions>,
 ): Promise<CliCommandReturnVal<{ entityRef: EntityRef }>> {
     const providedVendurePlugin = options?.plugin;
-    const { project } = await analyzeProject({ providedVendurePlugin, cancelledMessage });
+    const { project } = await analyzeProject({ providedVendurePlugin, cancelledMessage, config: options?.config });
+
+    // In non-interactive mode with no plugin specified, we cannot proceed
+    if (options?.className && !providedVendurePlugin) {
+        throw new Error('Plugin must be specified when running in non-interactive mode. Use selectPlugin in interactive mode.');
+    }
+
     const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
     const modifiedSourceFiles: SourceFile[] = [];
 
@@ -48,6 +56,7 @@ async function addEntity(
         fileName: paramCase(customEntityName) + '.entity',
         translationFileName: paramCase(customEntityName) + '-translation.entity',
         features: await getFeatures(options),
+        config: options?.config,
     };
 
     const entitySpinner = spinner();
@@ -77,6 +86,13 @@ async function getFeatures(options?: Partial<AddEntityOptions>): Promise<AddEnti
     if (options?.features) {
         return options?.features;
     }
+    // Default features for non-interactive mode when not specified
+    if (options?.className && !options?.features) {
+        return {
+            customFields: true,
+            translatable: false,
+        };
+    }
     const features = await multiselect({
         message: 'Entity features (use ↑, ↓, space to select)',
         required: false,

+ 86 - 11
packages/cli/src/commands/add/job-queue/add-job-queue.ts

@@ -4,14 +4,19 @@ import { Node, Scope } from 'ts-morph';
 
 import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { ServiceRef } from '../../../shared/service-ref';
-import { analyzeProject, selectPlugin, selectServiceRef } from '../../../shared/shared-prompts';
+import { analyzeProject, selectPlugin, selectServiceRef, getServices } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
-import { addImportsToFile } from '../../../utilities/ast-utils';
+import { addImportsToFile, getPluginClasses } from '../../../utilities/ast-utils';
+import { addServiceCommand } from '../service/add-service';
 
 const cancelledMessage = 'Add API extension cancelled';
 
 export interface AddJobQueueOptions {
     plugin?: VendurePluginRef;
+    pluginName?: string;
+    name?: string;
+    config?: string;
+    isNonInteractive?: boolean;
 }
 
 export const addJobQueueCommand = new CliCommand({
@@ -25,11 +30,81 @@ async function addJobQueue(
     options?: AddJobQueueOptions,
 ): Promise<CliCommandReturnVal<{ serviceRef: ServiceRef }>> {
     const providedVendurePlugin = options?.plugin;
-    const { project } = await analyzeProject({ providedVendurePlugin, cancelledMessage });
-    const plugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
-    const serviceRef = await selectServiceRef(project, plugin);
+    const { project } = await analyzeProject({ providedVendurePlugin, cancelledMessage, config: options?.config });
+
+    // Detect non-interactive mode
+    const isNonInteractive = options?.isNonInteractive === true;
+    
+    let plugin: VendurePluginRef | undefined = providedVendurePlugin;
+    
+    // If a plugin name was provided, try to find it
+    if (!plugin && options?.pluginName) {
+        const pluginClasses = getPluginClasses(project);
+        const foundPlugin = pluginClasses.find(p => p.getName() === options.pluginName);
+        
+        if (!foundPlugin) {
+            // List available plugins if the specified one wasn't found
+            const availablePlugins = pluginClasses.map(p => p.getName()).filter(Boolean);
+            throw new Error(
+                `Plugin "${options.pluginName}" not found. Available plugins:\n` +
+                availablePlugins.map(name => `  - ${name}`).join('\n')
+            );
+        }
+        
+        plugin = new VendurePluginRef(foundPlugin);
+    }
+    
+    // In non-interactive mode, we need all required values upfront
+    if (isNonInteractive) {
+        if (!plugin) {
+            throw new Error('Plugin must be specified when running in non-interactive mode');
+        }
+        // Don't require name - we'll use a default
+    }
 
-    const jobQueueName = await text({
+    plugin = plugin ?? (await selectPlugin(project, cancelledMessage));
+    
+    // In non-interactive mode, we cannot prompt for service selection
+    if (isNonInteractive && !plugin) {
+        throw new Error('Cannot select service in non-interactive mode - plugin must be specified');
+    }
+    
+    let serviceRef: ServiceRef | undefined;
+    
+    if (isNonInteractive) {
+        // In non-interactive mode, find existing services or create a new one
+        const existingServices = getServices(project).filter(sr => {
+            return sr.classDeclaration
+                .getSourceFile()
+                .getDirectoryPath()
+                .includes(plugin.getSourceFile().getDirectoryPath());
+        });
+        
+        if (existingServices.length > 0) {
+            // Use the first available service
+            serviceRef = existingServices[0];
+            log.info(`Using existing service: ${serviceRef.name}`);
+        } else {
+            // Create a new service automatically
+            log.info('No existing services found, creating a new service...');
+            const result = await addServiceCommand.run({ 
+                type: 'basic', 
+                plugin,
+                serviceName: 'GeneratedService',
+                isNonInteractive: true
+            });
+            serviceRef = result.serviceRef;
+        }
+    } else {
+        // Interactive mode - let user choose
+        serviceRef = await selectServiceRef(project, plugin);
+    }
+
+    if (!serviceRef) {
+        throw new Error('Service is required for job queue');
+    }
+
+    const jobQueueName = options?.name ?? (isNonInteractive ? 'my-job-queue' : await text({
         message: 'What is the name of the job queue?',
         initialValue: 'my-background-task',
         validate: input => {
@@ -37,9 +112,9 @@ async function addJobQueue(
                 return 'The job queue name must be lowercase and contain only letters, numbers and dashes';
             }
         },
-    });
+    }));
 
-    if (isCancel(jobQueueName)) {
+    if (!isNonInteractive && isCancel(jobQueueName)) {
         cancel(cancelledMessage);
         process.exit(0);
     }
@@ -64,7 +139,7 @@ async function addJobQueue(
         type: 'JobQueueService',
     });
 
-    const jobQueuePropertyName = camelCase(jobQueueName) + 'Queue';
+    const jobQueuePropertyName = camelCase(jobQueueName as string) + 'Queue';
 
     serviceRef.classDeclaration.insertProperty(0, {
         name: jobQueuePropertyName,
@@ -94,7 +169,7 @@ async function addJobQueue(
             writer
                 .write(
                     `this.${jobQueuePropertyName} = await this.jobQueueService.createQueue({
-                name: '${jobQueueName}',
+                name: '${jobQueueName as string}',
                 process: async job => {
                     // Deserialize the RequestContext from the job data
                     const ctx = RequestContext.deserialize(job.data.ctx);
@@ -134,7 +209,7 @@ async function addJobQueue(
 
     serviceRef.classDeclaration
         .addMethod({
-            name: `trigger${pascalCase(jobQueueName)}`,
+            name: `trigger${pascalCase(jobQueueName as string)}`,
             scope: Scope.Public,
             parameters: [{ name: 'ctx', type: 'RequestContext' }],
             statements: writer => {

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

@@ -23,15 +23,19 @@ export const createNewPluginCommand = new CliCommand({
     id: 'create-new-plugin',
     category: 'Plugin',
     description: 'Create a new Vendure plugin',
-    run: createNewPlugin,
+    run: (options?: Partial<GeneratePluginOptions>) => createNewPlugin(options),
 });
 
 const cancelledMessage = 'Plugin setup cancelled.';
 
-export async function createNewPlugin(): Promise<CliCommandReturnVal> {
-    const options: GeneratePluginOptions = { name: '', customEntityName: '', pluginDir: '' } as any;
-    intro('Adding a new Vendure plugin!');
-    const { project } = await analyzeProject({ cancelledMessage });
+export async function createNewPlugin(
+    options: Partial<GeneratePluginOptions> = {},
+): Promise<CliCommandReturnVal> {
+    const isNonInteractive = !!options.name;
+    if (!isNonInteractive) {
+        intro('Adding a new Vendure plugin!');
+    }
+    const { project, config } = await analyzeProject({ cancelledMessage, config: options.config });
     if (!options.name) {
         const name = await text({
             message: 'What is the name of the plugin?',
@@ -51,30 +55,38 @@ export async function createNewPlugin(): Promise<CliCommandReturnVal> {
         }
     }
     const existingPluginDir = findExistingPluginsDir(project);
-    const pluginDir = getPluginDirName(options.name, existingPluginDir);
-    const confirmation = await text({
-        message: 'Plugin location',
-        initialValue: pluginDir,
-        placeholder: '',
-        validate: input => {
-            if (fs.existsSync(input)) {
-                return `A directory named "${input}" already exists. Please specify a different directory.`;
-            }
-        },
-    });
+    const pluginDir = getPluginDirName(options.name , existingPluginDir);
+
+    if (isNonInteractive) {
+        options.pluginDir = pluginDir;
+        if (fs.existsSync(options.pluginDir)) {
+            throw new Error(`A directory named "${options.pluginDir}" already exists.`);
+        }
+    } else {
+        const confirmation = await text({
+            message: 'Plugin location',
+            initialValue: pluginDir,
+            placeholder: '',
+            validate: input => {
+                if (fs.existsSync(input)) {
+                    return `A directory named "${input}" already exists. Please specify a different directory.`;
+                }
+            },
+        });
 
-    if (isCancel(confirmation)) {
-        cancel(cancelledMessage);
-        process.exit(0);
+        if (isCancel(confirmation)) {
+            cancel(cancelledMessage);
+            process.exit(0);
+        }
+        options.pluginDir = confirmation;
     }
 
-    options.pluginDir = confirmation;
-    const { plugin, modifiedSourceFiles } = await generatePlugin(project, options);
+    const { plugin, modifiedSourceFiles } = await generatePlugin(project, options as GeneratePluginOptions);
 
     const configSpinner = spinner();
     configSpinner.start('Updating VendureConfig...');
     await pauseForPromptDisplay();
-    const vendureConfig = new VendureConfigRef(project);
+    const vendureConfig = new VendureConfigRef(project, config);
     vendureConfig.addToPluginsArray(`${plugin.name}.init({})`);
     addImportsToFile(vendureConfig.sourceFile, {
         moduleSpecifier: plugin.getSourceFile(),
@@ -83,6 +95,12 @@ export async function createNewPlugin(): Promise<CliCommandReturnVal> {
     await vendureConfig.sourceFile.getProject().save();
     configSpinner.stop('Updated VendureConfig');
 
+    if (isNonInteractive) {
+        return {
+            project,
+            modifiedSourceFiles: [],
+        };
+    }
     let done = false;
     const followUpCommands = [
         addEntityCommand,

+ 1 - 0
packages/cli/src/commands/add/plugin/types.ts

@@ -1,6 +1,7 @@
 export interface GeneratePluginOptions {
     name: string;
     pluginDir: string;
+    config?: string;
 }
 
 export type NewPluginTemplateContext = GeneratePluginOptions & {

+ 30 - 18
packages/cli/src/commands/add/service/add-service.ts

@@ -24,6 +24,8 @@ interface AddServiceOptions {
     type: 'basic' | 'entity';
     serviceName: string;
     entityRef?: EntityRef;
+    config?: string;
+    isNonInteractive?: boolean;
 }
 
 export const addServiceCommand = new CliCommand({
@@ -37,12 +39,19 @@ async function addService(
     providedOptions?: Partial<AddServiceOptions>,
 ): Promise<CliCommandReturnVal<{ serviceRef: ServiceRef }>> {
     const providedVendurePlugin = providedOptions?.plugin;
-    const { project } = await analyzeProject({ providedVendurePlugin, cancelledMessage });
+    const { project } = await analyzeProject({ providedVendurePlugin, cancelledMessage, config: providedOptions?.config });
+
+    // In non-interactive mode with no plugin specified, we cannot proceed
+    const isNonInteractive = providedOptions?.serviceName !== undefined;
+    if (isNonInteractive && !providedVendurePlugin) {
+        throw new Error('Plugin must be specified when running in non-interactive mode. Use selectPlugin in interactive mode.');
+    }
+
     const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
     const modifiedSourceFiles: SourceFile[] = [];
     const type =
         providedOptions?.type ??
-        (await select({
+        (isNonInteractive ? 'basic' : await select({
             message: 'What type of service would you like to add?',
             options: [
                 { value: 'basic', label: 'Basic empty service' },
@@ -50,13 +59,14 @@ async function addService(
             ],
             maxItems: 10,
         }));
-    if (isCancel(type)) {
+    if (!isNonInteractive && isCancel(type)) {
         cancel('Cancelled');
         process.exit(0);
     }
     const options: AddServiceOptions = {
         type: type as AddServiceOptions['type'],
-        serviceName: 'MyService',
+        serviceName: providedOptions?.serviceName ?? 'MyService',
+        config: providedOptions?.config,
     };
     if (type === 'entity') {
         let entityRef: EntityRef;
@@ -81,25 +91,27 @@ async function addService(
     let serviceSourceFile: SourceFile;
     let serviceClassDeclaration: ClassDeclaration;
     if (options.type === 'basic') {
-        const name = await text({
-            message: 'What is the name of the new service?',
-            initialValue: 'MyService',
-            validate: input => {
-                if (!input) {
-                    return 'The service name cannot be empty';
-                }
-                if (!pascalCaseRegex.test(input)) {
-                    return 'The service name must be in PascalCase, e.g. "MyService"';
-                }
-            },
-        });
+        const name = options.serviceName !== 'MyService'
+            ? options.serviceName
+            : await text({
+                message: 'What is the name of the new service?',
+                initialValue: 'MyService',
+                validate: input => {
+                    if (!input) {
+                        return 'The service name cannot be empty';
+                    }
+                    if (!pascalCaseRegex.test(input)) {
+                        return 'The service name must be in PascalCase, e.g. "MyService"';
+                    }
+                },
+            });
 
-        if (isCancel(name)) {
+        if (!isNonInteractive && isCancel(name)) {
             cancel(cancelledMessage);
             process.exit(0);
         }
 
-        options.serviceName = name;
+        options.serviceName = name as string;
         serviceSpinner.start(`Creating ${options.serviceName}...`);
         const serviceSourceFilePath = getServiceFilePath(vendurePlugin, options.serviceName);
         await pauseForPromptDisplay();

+ 35 - 5
packages/cli/src/commands/add/ui-extensions/add-ui-extensions.ts

@@ -7,13 +7,16 @@ import { PackageJson } from '../../../shared/package-json-ref';
 import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
-import { createFile, getRelativeImportPath } from '../../../utilities/ast-utils';
+import { createFile, getRelativeImportPath, getPluginClasses } from '../../../utilities/ast-utils';
 
 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';
 
 export interface AddUiExtensionsOptions {
     plugin?: VendurePluginRef;
+    pluginName?: string;
+    config?: string;
+    isNonInteractive?: boolean;
 }
 
 export const addUiExtensionsCommand = new CliCommand<AddUiExtensionsOptions>({
@@ -25,9 +28,36 @@ export const addUiExtensionsCommand = new CliCommand<AddUiExtensionsOptions>({
 
 async function addUiExtensions(options?: AddUiExtensionsOptions): Promise<CliCommandReturnVal> {
     const providedVendurePlugin = options?.plugin;
-    const { project } = await analyzeProject({ providedVendurePlugin });
-    const vendurePlugin =
-        providedVendurePlugin ?? (await selectPlugin(project, 'Add UI extensions cancelled'));
+    const { project } = await analyzeProject({ providedVendurePlugin, config: options?.config });
+
+    // Detect non-interactive mode
+    const isNonInteractive = options?.isNonInteractive === true;
+    
+    let vendurePlugin: VendurePluginRef | undefined = providedVendurePlugin;
+    
+    // If a plugin name was provided, try to find it
+    if (!vendurePlugin && options?.pluginName) {
+        const pluginClasses = getPluginClasses(project);
+        const foundPlugin = pluginClasses.find(p => p.getName() === options.pluginName);
+        
+        if (!foundPlugin) {
+            // List available plugins if the specified one wasn't found
+            const availablePlugins = pluginClasses.map(p => p.getName()).filter(Boolean);
+            throw new Error(
+                `Plugin "${options.pluginName}" not found. Available plugins:\n` +
+                availablePlugins.map(name => `  - ${name}`).join('\n')
+            );
+        }
+        
+        vendurePlugin = new VendurePluginRef(foundPlugin);
+    }
+    
+    // In non-interactive mode, we need a plugin specified
+    if (isNonInteractive && !vendurePlugin) {
+        throw new Error('Plugin must be specified when running in non-interactive mode');
+    }
+
+    vendurePlugin = vendurePlugin ?? (await selectPlugin(project, 'Add UI extensions cancelled'));
     const packageJson = new PackageJson(project);
 
     if (vendurePlugin.hasUiExtensions()) {
@@ -78,7 +108,7 @@ async function addUiExtensions(options?: AddUiExtensionsOptions): Promise<CliCom
 
     log.success('Created UI extension scaffold');
 
-    const vendureConfig = new VendureConfigRef(project);
+    const vendureConfig = new VendureConfigRef(project, options?.config);
     if (!vendureConfig) {
         log.warning(
             `Could not find the VendureConfig declaration in your project. You will need to manually set up the compileUiExtensions function.`,

+ 2 - 2
packages/cli/src/commands/add/ui-extensions/codemods/update-admin-ui-plugin-init/update-admin-ui-plugin-init.spec.ts

@@ -14,7 +14,7 @@ describe('updateAdminUiPluginInit', () => {
             manipulationSettings: defaultManipulationSettings,
         });
         project.addSourceFileAtPath(path.join(__dirname, 'fixtures', 'no-app-prop.fixture.ts'));
-        const vendureConfig = new VendureConfigRef(project, { checkFileName: false });
+        const vendureConfig = new VendureConfigRef(project);
         updateAdminUiPluginInit(vendureConfig, {
             pluginClassName: 'TestPlugin',
             pluginPath: './plugins/test-plugin/test.plugin',
@@ -32,7 +32,7 @@ describe('updateAdminUiPluginInit', () => {
             manipulationSettings: defaultManipulationSettings,
         });
         project.addSourceFileAtPath(path.join(__dirname, 'fixtures', 'existing-app-prop.fixture.ts'));
-        const vendureConfig = new VendureConfigRef(project, { checkFileName: false });
+        const vendureConfig = new VendureConfigRef(project);
         updateAdminUiPluginInit(vendureConfig, {
             pluginClassName: 'TestPlugin',
             pluginPath: './plugins/test-plugin/test.plugin',

+ 15 - 10
packages/cli/src/commands/command-declarations.ts

@@ -5,6 +5,11 @@ export const cliCommands: CliCommandDefinition[] = [
         name: 'add',
         description: 'Add a feature to your Vendure project',
         options: [
+            {
+                long: '--config <path>',
+                description: 'Specify the path to a custom Vendure config file',
+                required: false,
+            },
             {
                 short: '-p',
                 long: '--plugin <name>',
@@ -14,37 +19,37 @@ export const cliCommands: CliCommandDefinition[] = [
             {
                 short: '-e',
                 long: '--entity <name>',
-                description: 'Add a new entity with the specified class name',
+                description: 'Add a new entity with the specified class name (requires plugin context)',
                 required: false,
             },
             {
                 short: '-s',
                 long: '--service <name>',
-                description: 'Add a new service with the specified class name',
+                description: 'Add a new service with the specified class name (requires plugin context)',
                 required: false,
             },
             {
                 short: '-j',
-                long: '--job-queue',
-                description: 'Add job-queue support',
+                long: '--job-queue [plugin]',
+                description: 'Add job-queue support to the specified plugin',
                 required: false,
             },
             {
                 short: '-c',
-                long: '--codegen',
-                description: 'Add GraphQL codegen configuration',
+                long: '--codegen [plugin]',
+                description: 'Add GraphQL codegen configuration to the specified plugin',
                 required: false,
             },
             {
                 short: '-a',
-                long: '--api-extension',
-                description: 'Add an API extension scaffold',
+                long: '--api-extension [plugin]',
+                description: 'Add an API extension scaffold to the specified plugin',
                 required: false,
             },
             {
                 short: '-u',
-                long: '--ui-extensions',
-                description: 'Add admin-UI / storefront UI extensions setup',
+                long: '--ui-extensions [plugin]',
+                description: 'Add Admin UI extensions setup to the specified plugin',
                 required: false,
             },
         ],

+ 12 - 2
packages/cli/src/shared/shared-prompts.ts

@@ -13,6 +13,7 @@ import { VendurePluginRef } from './vendure-plugin-ref';
 export async function analyzeProject(options: {
     providedVendurePlugin?: VendurePluginRef;
     cancelledMessage?: string;
+    config?: string;
 }) {
     const providedVendurePlugin = options.providedVendurePlugin;
     let project = providedVendurePlugin?.classDeclaration.getProject();
@@ -23,12 +24,21 @@ export async function analyzeProject(options: {
         const tsConfigFile = await selectTsConfigFile();
         projectSpinner.start('Analyzing project...');
         await pauseForPromptDisplay();
-        const { project: _project, tsConfigPath: _tsConfigPath } = await getTsMorphProject({}, tsConfigFile);
+        const { project: _project, tsConfigPath: _tsConfigPath } = await getTsMorphProject(
+            {
+                compilerOptions: {
+                    // When running via the CLI, we want to find all source files,
+                    // not just the compiled .js files.
+                    rootDir: './src',
+                },
+            },
+            tsConfigFile,
+        );
         project = _project;
         tsConfigPath = _tsConfigPath;
         projectSpinner.stop('Project analyzed');
     }
-    return { project: project as Project, tsConfigPath };
+    return { project: project as Project, tsConfigPath, config: options.config };
 }
 
 export async function selectPlugin(project: Project, cancelledMessage: string): Promise<VendurePluginRef> {

+ 33 - 35
packages/cli/src/shared/vendure-config-ref.ts

@@ -15,45 +15,32 @@ export class VendureConfigRef {
 
     constructor(
         private project: Project,
-        options: { checkFileName?: boolean } = {},
+        configFilePath?: string,
     ) {
-        const checkFileName = options.checkFileName ?? true;
-
-        const getVendureConfigSourceFile = (sourceFiles: SourceFile[]) => {
-            return sourceFiles.find(sf => {
-                return (
-                    (checkFileName ? sf.getFilePath().endsWith('vendure-config.ts') : true) &&
-                    sf.getVariableDeclarations().find(v => this.isVendureConfigVariableDeclaration(v))
+        if (configFilePath) {
+            const sourceFile = project.getSourceFile(sf => sf.getFilePath().endsWith(configFilePath));
+            if (!sourceFile) {
+                throw new Error(`Could not find a config file at "${configFilePath}"`);
+            }
+            this.sourceFile = sourceFile;
+        } else {
+            const sourceFiles = project
+                .getSourceFiles()
+                .filter(sf => this.isVendureConfigFile(sf, { checkFileName: false }));
+            if (sourceFiles.length > 1) {
+                throw new Error(
+                    `Multiple Vendure config files found. Please specify which one to use with the --config flag:\n` +
+                        sourceFiles.map(sf => `  - ${sf.getFilePath()}`).join('\n'),
                 );
-            });
-        };
-
-        const findAndAddVendureConfigToProject = () => {
-            // If the project does not contain a vendure-config.ts file, we'll look for a vendure-config.ts file
-            // in the src directory.
-            const srcDir = project.getDirectory('src');
-            if (srcDir) {
-                const srcDirPath = srcDir.getPath();
-                const srcFiles = fs.readdirSync(srcDirPath);
-
-                const filePath = srcFiles.find(file => file.includes('vendure-config.ts'));
-                if (filePath) {
-                    project.addSourceFileAtPath(path.join(srcDirPath, filePath));
-                }
             }
-        };
-
-        let vendureConfigFile = getVendureConfigSourceFile(project.getSourceFiles());
-        if (!vendureConfigFile) {
-            findAndAddVendureConfigToProject();
-            vendureConfigFile = getVendureConfigSourceFile(project.getSourceFiles());
-        }
-        if (!vendureConfigFile) {
-            throw new Error('Could not find the VendureConfig declaration in your project.');
+            if (sourceFiles.length === 0) {
+                throw new Error('Could not find the VendureConfig declaration in your project.');
+            }
+            this.sourceFile = sourceFiles[0];
         }
-        this.sourceFile = vendureConfigFile;
-        this.configObject = vendureConfigFile
-            ?.getVariableDeclarations()
+
+        this.configObject = this.sourceFile
+            .getVariableDeclarations()
             .find(v => this.isVendureConfigVariableDeclaration(v))
             ?.getChildren()
             .find(Node.isObjectLiteralExpression) as ObjectLiteralExpression;
@@ -83,6 +70,17 @@ export class VendureConfigRef {
         this.getPluginsArray()?.addElement(text).formatText();
     }
 
+    private isVendureConfigFile(
+        sourceFile: SourceFile,
+        options: { checkFileName?: boolean } = {},
+    ): boolean {
+        const checkFileName = options.checkFileName ?? true;
+        return (
+            (checkFileName ? sourceFile.getFilePath().endsWith('vendure-config.ts') : true) &&
+            !!sourceFile.getVariableDeclarations().find(v => this.isVendureConfigVariableDeclaration(v))
+        );
+    }
+
     private isVendureConfigVariableDeclaration(v: VariableDeclaration) {
         return v.getType().getText(v) === 'VendureConfig';
     }