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

feat(cli): Non-interactive mode and new structure (#3606)

Co-authored-by: HouseinIsProgramming <76689341+GogoIsPrograming@users.noreply.github.com>
Co-authored-by: Housein Abo Shaar <76689341+GogoIsProgramming@users.noreply.github.com>
Housein Abo Shaar 6 месяцев назад
Родитель
Сommit
7d9f03bf2b
40 измененных файлов с 3134 добавлено и 321 удалено
  1. 184 1
      docs/docs/guides/developer-guide/cli/index.md
  2. 3 0
      packages/cli/.gitignore
  3. 201 0
      packages/cli/e2e/add-command.e2e-spec.ts
  4. 4 0
      packages/cli/e2e/config/tsconfig.e2e.json
  5. 14 0
      packages/cli/e2e/fixtures/test-entity.ts
  6. 7 0
      packages/cli/e2e/fixtures/test-project/package.json
  7. 23 0
      packages/cli/e2e/fixtures/test-project/src/vendure-config.ts
  8. 14 0
      packages/cli/e2e/fixtures/test-project/tsconfig.json
  9. 22 0
      packages/cli/e2e/fixtures/vendure-config.ts
  10. 411 0
      packages/cli/e2e/migrate-command.e2e-spec.ts
  11. 31 0
      packages/cli/e2e/vitest.e2e.config.mts
  12. 2 1
      packages/cli/package.json
  13. 270 0
      packages/cli/src/README.md
  14. 5 17
      packages/cli/src/cli.ts
  15. 258 0
      packages/cli/src/commands/add/add-operations.ts
  16. 57 8
      packages/cli/src/commands/add/add.ts
  17. 128 23
      packages/cli/src/commands/add/api-extension/add-api-extension.ts
  18. 18 6
      packages/cli/src/commands/add/codegen/add-codegen.ts
  19. 73 20
      packages/cli/src/commands/add/entity/add-entity.ts
  20. 56 0
      packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/add-entity-to-plugin.spec.ts
  21. 91 18
      packages/cli/src/commands/add/job-queue/add-job-queue.ts
  22. 57 31
      packages/cli/src/commands/add/plugin/create-new-plugin.ts
  23. 1 0
      packages/cli/src/commands/add/plugin/types.ts
  24. 99 38
      packages/cli/src/commands/add/service/add-service.ts
  25. 35 5
      packages/cli/src/commands/add/ui-extensions/add-ui-extensions.ts
  26. 2 2
      packages/cli/src/commands/add/ui-extensions/codemods/update-admin-ui-plugin-init/update-admin-ui-plugin-init.spec.ts
  27. 160 0
      packages/cli/src/commands/command-declarations.ts
  28. 35 26
      packages/cli/src/commands/migrate/generate-migration/generate-migration.ts
  29. 87 11
      packages/cli/src/commands/migrate/migrate.ts
  30. 299 0
      packages/cli/src/commands/migrate/migration-operations.spec.ts
  31. 148 0
      packages/cli/src/commands/migrate/migration-operations.ts
  32. 19 0
      packages/cli/src/shared/cli-command-definition.ts
  33. 50 0
      packages/cli/src/shared/command-registry.ts
  34. 51 0
      packages/cli/src/shared/plugin-resolution.ts
  35. 41 0
      packages/cli/src/shared/project-validation.ts
  36. 89 60
      packages/cli/src/shared/shared-prompts.ts
  37. 33 35
      packages/cli/src/shared/vendure-config-ref.ts
  38. 12 18
      packages/cli/src/utilities/ast-utils.ts
  39. 42 0
      packages/cli/src/utilities/utils.ts
  40. 2 1
      packages/cli/tsconfig.json

+ 184 - 1
docs/docs/guides/developer-guide/cli/index.md

@@ -36,10 +36,19 @@ yarn add -D @vendure/cli
 </TabItem>
 </Tabs>
 
+## Interactive vs Non-Interactive Mode
+
+The Vendure CLI supports both **interactive** and **non-interactive** modes:
+
+- **Interactive mode**: Provides guided prompts and menus for easy use during development
+- **Non-interactive mode**: Allows direct command execution with arguments and options, perfect for automation, CI/CD, and AI agents
+
 ## The Add Command
 
 The `add` command is used to add new entities, resolvers, services, plugins, and more to your Vendure project.
 
+### Interactive Mode
+
 From your project's **root directory**, run:
 
 <Tabs groupId="package-manager">
@@ -66,10 +75,124 @@ The CLI will guide you through the process of adding new functionality to your p
 The `add` command is much more than a simple file generator. It is able to
 analyze your project source code to deeply understand and correctly update your project files.
 
+### Non-Interactive Mode
+
+For automation or when you know exactly what you need to add, you can use the non-interactive mode with specific arguments and options:
+
+<Tabs groupId="package-manager">
+<TabItem value="npm" label="npm" default>
+
+```bash
+# Create a new plugin
+npx vendure add -p MyPlugin
+
+# Add an entity to a plugin
+npx vendure add -e MyEntity --selected-plugin MyPlugin
+
+# Add an entity with features
+npx vendure add -e MyEntity --selected-plugin MyPlugin --custom-fields --translatable
+
+# Add a service to a plugin
+npx vendure add -s MyService --selected-plugin MyPlugin
+
+# Add a service with specific type
+npx vendure add -s MyService --selected-plugin MyPlugin --type entity
+
+# Add job queue support to a plugin
+npx vendure add -j MyPlugin --name my-job --selected-service MyService
+
+# Add GraphQL codegen to a plugin
+npx vendure add -c MyPlugin
+
+# Add API extension to a plugin
+npx vendure add -a MyPlugin --queryName getCustomData --mutationName updateCustomData
+
+# Add UI extensions to a plugin
+npx vendure add -u MyPlugin
+
+# Use custom config file
+npx vendure add -p MyPlugin --config ./custom-vendure.config.ts
+```
+
+</TabItem>
+<TabItem value="yarn" label="yarn">
+
+```bash
+# Create a new plugin
+yarn vendure add -p MyPlugin
+
+# Add an entity to a plugin
+yarn vendure add -e MyEntity --selected-plugin MyPlugin
+
+# Add an entity with features
+yarn vendure add -e MyEntity --selected-plugin MyPlugin --custom-fields --translatable
+
+# Add a service to a plugin
+yarn vendure add -s MyService --selected-plugin MyPlugin
+
+# Add a service with specific type
+yarn vendure add -s MyService --selected-plugin MyPlugin --type entity
+
+# Add job queue support to a plugin
+yarn vendure add -j MyPlugin --name my-job --selected-service MyService
+
+# Add GraphQL codegen to a plugin
+yarn vendure add -c MyPlugin
+
+# Add API extension to a plugin
+yarn vendure add -a MyPlugin --queryName getCustomData --mutationName updateCustomData
+
+# Add UI extensions to a plugin
+yarn vendure add -u MyPlugin
+
+# Use custom config file
+yarn vendure add -p MyPlugin --config ./custom-vendure.config.ts
+```
+
+</TabItem>
+</Tabs>
+
+#### Add Command Options
+
+| Flag | Long Form | Description | Example |
+|------|-----------|-------------|---------|
+| `-p` | `--plugin <n>` | Create a new plugin | `vendure add -p MyPlugin` |
+| `-e` | `--entity <n>` | Add a new entity to a plugin | `vendure add -e MyEntity --selected-plugin MyPlugin` |
+| `-s` | `--service <n>` | Add a new service to a plugin | `vendure add -s MyService --selected-plugin MyPlugin` |
+| `-j` | `--job-queue [plugin]` | Add job queue support | `vendure add -j MyPlugin --name job-name --selected-service ServiceName` |
+| `-c` | `--codegen [plugin]` | Add GraphQL codegen configuration | `vendure add -c MyPlugin` |
+| `-a` | `--api-extension [plugin]` | Add API extension scaffold | `vendure add -a MyPlugin --queryName getName --mutationName setName` |
+| `-u` | `--ui-extensions [plugin]` | Add UI extensions setup | `vendure add -u MyPlugin` |
+| | `--config <path>` | Specify custom Vendure config file | `--config ./custom-config.ts` |
+
+#### Sub-options for specific commands
+
+**Entity (`-e`) additional options:**
+- `--selected-plugin <n>`: Name of the plugin to add the entity to (required)
+- `--custom-fields`: Add custom fields support to the entity
+- `--translatable`: Make the entity translatable
+
+**Service (`-s`) additional options:**
+- `--selected-plugin <n>`: Name of the plugin to add the service to (required)
+- `--type <type>`: Type of service: basic or entity (default: basic)
+
+**Job Queue (`-j`) additional options:**
+- `--name <name>`: Name for the job queue (required)
+- `--selected-service <name>`: Service to add the job queue to (required)
+
+**API Extension (`-a`) additional options: (requires either)**
+- `--queryName <n>`: Name for the GraphQL query
+- `--mutationName <n>`: Name for the GraphQL mutation
+
+:::info
+**Validation**: Entity and service commands validate that the specified plugin exists in your project. If the plugin is not found, the command will list all available plugins in the error message. Both commands require the `--plugin` parameter when running in non-interactive mode.
+:::
+
 ## The Migrate Command
 
 The `migrate` command is used to generate and manage [database migrations](/guides/developer-guide/migrations) for your Vendure project.
 
+### Interactive Mode
 
 From your project's **root directory**, run:
 
@@ -90,4 +213,64 @@ yarn vendure migrate
 </TabItem>
 </Tabs>
 
-![Migrate command](./migrate-command.webp)
+![Migrate command](./migrate-command.webp)
+
+### Non-Interactive Mode
+
+For migration operations, use specific arguments and options:
+
+<Tabs groupId="package-manager">
+<TabItem value="npm" label="npm" default>
+
+```bash
+# Generate a new migration
+npx vendure migrate -g my-migration-name
+
+# Run pending migrations
+npx vendure migrate -r
+
+# Revert the last migration
+npx vendure migrate --revert
+
+# Generate migration with custom output directory
+npx vendure migrate -g my-migration -o ./custom/migrations
+```
+
+</TabItem>
+<TabItem value="yarn" label="yarn">
+
+```bash
+# Generate a new migration
+yarn vendure migrate -g my-migration-name
+
+# Run pending migrations
+yarn vendure migrate -r
+
+# Revert the last migration
+yarn vendure migrate --revert
+
+# Generate migration with custom output directory
+yarn vendure migrate -g my-migration -o ./custom/migrations
+```
+
+</TabItem>
+</Tabs>
+
+#### Migrate Command Options
+
+| Flag | Long Form | Description | Example |
+|------|-----------|-------------|---------|
+| `-g` | `--generate <name>` | Generate a new migration | `vendure migrate -g add-user-table` |
+| `-r` | `--run` | Run all pending migrations | `vendure migrate -r` |
+| | `--revert` | Revert the last migration | `vendure migrate --revert` |
+| `-o` | `--output-dir <path>` | Custom output directory for migrations | `vendure migrate -g my-migration -o ./migrations` |
+
+## Getting Help
+
+To see all available commands and options:
+
+```bash
+npx vendure --help
+npx vendure add --help
+npx vendure migrate --help
+```

+ 3 - 0
packages/cli/.gitignore

@@ -0,0 +1,3 @@
+dist/
+dist-e2e/
+node_modules/ 

+ 201 - 0
packages/cli/e2e/add-command.e2e-spec.ts

@@ -0,0 +1,201 @@
+/*
+ * E2E tests for the add command
+ *
+ * To run these tests:
+ * npm run vitest -- --config e2e/vitest.e2e.config.mts
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { performAddOperation } from '../src/commands/add/add-operations';
+import { addApiExtensionCommand } from '../src/commands/add/api-extension/add-api-extension';
+import { addCodegenCommand } from '../src/commands/add/codegen/add-codegen';
+import { addEntityCommand } from '../src/commands/add/entity/add-entity';
+import { addJobQueueCommand } from '../src/commands/add/job-queue/add-job-queue';
+import { createNewPluginCommand } from '../src/commands/add/plugin/create-new-plugin';
+import { addServiceCommand } from '../src/commands/add/service/add-service';
+import { addUiExtensionsCommand } from '../src/commands/add/ui-extensions/add-ui-extensions';
+
+type Spy = ReturnType<typeof vi.spyOn>;
+
+/**
+ * Utility to stub the `run` function of a CliCommand so that the command
+ * doesn't actually execute any file-system or project manipulation during the
+ * tests. The stub resolves immediately, emulating a successful CLI command.
+ */
+function stubRun(cmd: { run: (...args: any[]) => any }): Spy {
+    // Cast to 'any' to avoid over-constraining the generic type parameters that
+    // vitest uses on spyOn, which causes type inference issues in strict mode.
+    // The runtime behaviour (spying on an object method) is what matters for
+    // these tests – the precise compile-time types are not important.
+    return vi
+        .spyOn(cmd as any, 'run')
+        .mockResolvedValue({ project: undefined, modifiedSourceFiles: [] } as any);
+}
+
+let pluginRunSpy: Spy;
+let entityRunSpy: Spy;
+let serviceRunSpy: Spy;
+let jobQueueRunSpy: Spy;
+let codegenRunSpy: Spy;
+let apiExtRunSpy: Spy;
+let uiExtRunSpy: Spy;
+
+beforeEach(() => {
+    // Stub all sub-command `run` handlers before every test
+    pluginRunSpy = stubRun(createNewPluginCommand);
+    entityRunSpy = stubRun(addEntityCommand);
+    serviceRunSpy = stubRun(addServiceCommand);
+    jobQueueRunSpy = stubRun(addJobQueueCommand);
+    codegenRunSpy = stubRun(addCodegenCommand);
+    apiExtRunSpy = stubRun(addApiExtensionCommand);
+    uiExtRunSpy = stubRun(addUiExtensionsCommand);
+});
+
+afterEach(() => {
+    vi.restoreAllMocks();
+});
+
+describe('Add Command E2E', () => {
+    describe('performAddOperation', () => {
+        it('creates a plugin when the "plugin" option is provided', async () => {
+            const result = await performAddOperation({ plugin: 'test-plugin' });
+
+            expect(pluginRunSpy).toHaveBeenCalledOnce();
+            expect(pluginRunSpy).toHaveBeenCalledWith({ name: 'test-plugin', config: undefined });
+            expect(result.success).toBe(true);
+            expect(result.message).toContain('test-plugin');
+        });
+
+        it('throws when the plugin name is empty', async () => {
+            await expect(performAddOperation({ plugin: '   ' } as any)).rejects.toThrow(
+                'Plugin name is required',
+            );
+            expect(pluginRunSpy).not.toHaveBeenCalled();
+        });
+
+        it('adds an entity to the specified plugin', async () => {
+            const result = await performAddOperation({
+                entity: 'MyEntity',
+                selectedPlugin: 'MyPlugin',
+            });
+
+            expect(entityRunSpy).toHaveBeenCalledOnce();
+            expect(entityRunSpy).toHaveBeenCalledWith({
+                className: 'MyEntity',
+                isNonInteractive: true,
+                config: undefined,
+                pluginName: 'MyPlugin',
+                customFields: undefined,
+                translatable: undefined,
+            });
+            expect(result.success).toBe(true);
+            expect(result.message).toContain('MyEntity');
+            expect(result.message).toContain('MyPlugin');
+        });
+
+        it('fails when adding an entity without specifying a plugin in non-interactive mode', async () => {
+            await expect(performAddOperation({ entity: 'MyEntity' })).rejects.toThrow(
+                'Plugin name is required when running in non-interactive mode',
+            );
+            expect(entityRunSpy).not.toHaveBeenCalled();
+        });
+
+        it('adds a service to the specified plugin', async () => {
+            const result = await performAddOperation({
+                service: 'MyService',
+                selectedPlugin: 'MyPlugin',
+            });
+
+            expect(serviceRunSpy).toHaveBeenCalledOnce();
+            expect(serviceRunSpy.mock.calls[0][0]).toMatchObject({
+                serviceName: 'MyService',
+                pluginName: 'MyPlugin',
+            });
+            expect(result.success).toBe(true);
+            expect(result.message).toContain('MyService');
+        });
+
+        it('adds a job-queue when required parameters are provided', async () => {
+            const options = {
+                jobQueue: 'MyPlugin',
+                name: 'ReindexJob',
+                selectedService: 'SearchService',
+            } as const;
+            const result = await performAddOperation(options);
+
+            expect(jobQueueRunSpy).toHaveBeenCalledOnce();
+            expect(jobQueueRunSpy.mock.calls[0][0]).toMatchObject({
+                pluginName: 'MyPlugin',
+                name: 'ReindexJob',
+                selectedService: 'SearchService',
+            });
+            expect(result.success).toBe(true);
+            expect(result.message).toContain('Job-queue');
+        });
+
+        it('fails when job-queue parameters are incomplete', async () => {
+            await expect(
+                performAddOperation({ jobQueue: true, name: 'JobWithoutService' } as any),
+            ).rejects.toThrow('Service name is required for job queue');
+            expect(jobQueueRunSpy).not.toHaveBeenCalled();
+        });
+
+        it('adds codegen configuration with boolean flag (interactive plugin selection)', async () => {
+            const result = await performAddOperation({ codegen: true });
+
+            expect(codegenRunSpy).toHaveBeenCalledOnce();
+            expect(codegenRunSpy.mock.calls[0][0]).toMatchObject({ pluginName: undefined });
+            expect(result.success).toBe(true);
+            expect(result.message).toContain('Codegen');
+        });
+
+        it('adds codegen configuration to a specific plugin when plugin name is supplied', async () => {
+            const result = await performAddOperation({ codegen: 'MyPlugin' });
+
+            expect(codegenRunSpy).toHaveBeenCalledOnce();
+            expect(codegenRunSpy.mock.calls[0][0]).toMatchObject({ pluginName: 'MyPlugin' });
+            expect(result.success).toBe(true);
+        });
+
+        it('adds an API extension scaffold when queryName is provided', async () => {
+            const result = await performAddOperation({
+                apiExtension: 'MyPlugin',
+                queryName: 'myQuery',
+            });
+
+            expect(apiExtRunSpy).toHaveBeenCalledOnce();
+            expect(apiExtRunSpy.mock.calls[0][0]).toMatchObject({
+                pluginName: 'MyPlugin',
+                queryName: 'myQuery',
+                mutationName: undefined,
+            });
+            expect(result.success).toBe(true);
+        });
+
+        it('fails when neither queryName nor mutationName is provided for API extension', async () => {
+            await expect(performAddOperation({ apiExtension: true } as any)).rejects.toThrow(
+                'At least one of queryName or mutationName must be specified',
+            );
+            expect(apiExtRunSpy).not.toHaveBeenCalled();
+        });
+
+        it('adds UI extensions when the uiExtensions flag is used', async () => {
+            const result = await performAddOperation({ uiExtensions: 'MyPlugin' });
+
+            expect(uiExtRunSpy).toHaveBeenCalledOnce();
+            expect(uiExtRunSpy.mock.calls[0][0]).toMatchObject({ pluginName: 'MyPlugin' });
+            expect(result.success).toBe(true);
+            expect(result.message).toContain('UI extensions');
+        });
+
+        it('returns a failure result when no valid operation is specified', async () => {
+            const result = await performAddOperation({});
+
+            expect(result.success).toBe(false);
+            expect(result.message).toContain('No valid add operation specified');
+            expect(pluginRunSpy).not.toHaveBeenCalled();
+            expect(entityRunSpy).not.toHaveBeenCalled();
+            expect(serviceRunSpy).not.toHaveBeenCalled();
+        });
+    });
+});

+ 4 - 0
packages/cli/e2e/config/tsconfig.e2e.json

@@ -0,0 +1,4 @@
+{
+  "extends": "../../../../e2e-common/tsconfig.e2e.json",
+  "include": ["../**/*.e2e-spec.ts", "../**/*.d.ts"]
+} 

+ 14 - 0
packages/cli/e2e/fixtures/test-entity.ts

@@ -0,0 +1,14 @@
+import { VendureEntity } from '@vendure/core';
+import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
+
+@Entity()
+export class TestEntity extends VendureEntity {
+    @PrimaryGeneratedColumn()
+    id: number;
+
+    @Column()
+    name: string;
+
+    @Column({ nullable: true })
+    description: string;
+}

+ 7 - 0
packages/cli/e2e/fixtures/test-project/package.json

@@ -0,0 +1,7 @@
+{
+  "name": "test-vendure-project",
+  "version": "1.0.0",
+  "dependencies": {
+    "@vendure/core": "3.3.4"
+  }
+} 

+ 23 - 0
packages/cli/e2e/fixtures/test-project/src/vendure-config.ts

@@ -0,0 +1,23 @@
+import { VendureConfig } from '@vendure/core';
+import * as path from 'path';
+
+export const config: VendureConfig = {
+    apiOptions: {
+        port: 3000,
+        adminApiPath: 'admin-api',
+        shopApiPath: 'shop-api',
+    },
+    dbConnectionOptions: {
+        type: 'sqlite',
+        database: path.join(__dirname, '../test.db'),
+        synchronize: false,
+        migrations: [path.join(__dirname, '../migrations/*.ts')],
+    },
+    authOptions: {
+        tokenMethod: 'bearer',
+    },
+    paymentOptions: {
+        paymentMethodHandlers: [],
+    },
+    plugins: [],
+};

+ 14 - 0
packages/cli/e2e/fixtures/test-project/tsconfig.json

@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "target": "es2020",
+    "module": "commonjs",
+    "lib": ["es2020"],
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "moduleResolution": "node",
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true
+  }
+} 

+ 22 - 0
packages/cli/e2e/fixtures/vendure-config.ts

@@ -0,0 +1,22 @@
+import { VendureConfig } from '@vendure/core';
+
+export const testConfig: VendureConfig = {
+    apiOptions: {
+        port: 3000,
+        adminApiPath: 'admin-api',
+        shopApiPath: 'shop-api',
+    },
+    dbConnectionOptions: {
+        type: 'sqlite',
+        database: ':memory:',
+        synchronize: false,
+        migrations: ['migrations/*.ts'],
+    },
+    authOptions: {
+        tokenMethod: 'bearer',
+    },
+    paymentOptions: {
+        paymentMethodHandlers: [],
+    },
+    plugins: [],
+};

+ 411 - 0
packages/cli/e2e/migrate-command.e2e-spec.ts

@@ -0,0 +1,411 @@
+/**
+ * E2E tests for the migrate command
+ *
+ * To run these tests:
+ * npm run vitest -- --config e2e/vitest.e2e.config.mts
+ */
+import * as fs from 'fs-extra';
+import * as path from 'path';
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+    generateMigrationOperation,
+    revertMigrationOperation,
+    runMigrationsOperation,
+} from '../src/commands/migrate/migration-operations';
+
+const TEST_PROJECT_DIR = path.join(__dirname, 'fixtures', 'test-project');
+const MIGRATIONS_DIR = path.join(TEST_PROJECT_DIR, 'migrations');
+
+describe('Migrate Command E2E', () => {
+    let originalCwd: string;
+
+    beforeAll(() => {
+        // Save the original working directory
+        originalCwd = process.cwd();
+    });
+
+    beforeEach(async () => {
+        // Clean up migrations directory before each test
+        await fs.emptyDir(MIGRATIONS_DIR);
+        // Clean up test database
+        const dbPath = path.join(TEST_PROJECT_DIR, 'test.db');
+        if (await fs.pathExists(dbPath)) {
+            await fs.remove(dbPath);
+        }
+    });
+
+    afterAll(async () => {
+        // Restore original working directory
+        process.chdir(originalCwd);
+        // Clean up after tests
+        await fs.emptyDir(MIGRATIONS_DIR);
+        const dbPath = path.join(TEST_PROJECT_DIR, 'test.db');
+        if (await fs.pathExists(dbPath)) {
+            await fs.remove(dbPath);
+        }
+    });
+
+    describe('generateMigrationOperation', () => {
+        it('should fail when not in a Vendure project directory', async () => {
+            // Run from a non-Vendure directory
+            process.chdir(__dirname);
+
+            const result = await generateMigrationOperation({ name: 'test-migration' });
+
+            expect(result.success).toBe(false);
+            expect(result.message).toContain('Not in a Vendure project directory');
+            expect(result.migrationName).toBeUndefined();
+        });
+
+        it('should generate a migration when in a valid Vendure project', async () => {
+            // Change to test project directory
+            process.chdir(TEST_PROJECT_DIR);
+
+            const result = await generateMigrationOperation({
+                name: 'AddTestEntity',
+                outputDir: MIGRATIONS_DIR,
+            });
+
+            expect(result.success).toBe(true);
+            expect(result.migrationName).toBeDefined();
+            expect(result.message).toContain('New migration generated');
+
+            // Verify migration file was created
+            const files = await fs.readdir(MIGRATIONS_DIR);
+            const migrationFile = files.find(f => f.includes('AddTestEntity'));
+            expect(migrationFile).toBeDefined();
+        });
+
+        it('should handle invalid migration names correctly', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            const invalidNames = [
+                '123-invalid', // starts with number
+                'test migration', // contains space
+                'test@migration', // special character
+            ];
+
+            for (const name of invalidNames) {
+                const result = await generateMigrationOperation({ name });
+
+                expect(result.success).toBe(false);
+                expect(result.message).toContain(
+                    'must contain only letters, numbers, underscores and dashes',
+                );
+                expect(result.migrationName).toBeUndefined();
+            }
+        });
+
+        it('should accept valid migration names', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            const validNames = [
+                'TestMigration',
+                'test-migration',
+                'test_migration',
+                'Migration123',
+                'ab', // minimum 2 characters
+            ];
+
+            for (const name of validNames) {
+                const result = await generateMigrationOperation({
+                    name,
+                    outputDir: MIGRATIONS_DIR,
+                });
+
+                // Since synchronize is false, generateMigration will create the initial migration
+                // The first time it runs, it will generate all tables
+                // Subsequent runs may report no changes
+                // Both are valid outcomes
+                expect(result.success).toBe(true);
+                expect(result.message).toBeDefined();
+
+                // Clean up the generated migration file for next iteration
+                const files = await fs.readdir(MIGRATIONS_DIR);
+                for (const file of files) {
+                    if (file.includes(name)) {
+                        await fs.remove(path.join(MIGRATIONS_DIR, file));
+                    }
+                }
+            }
+        });
+
+        it('should handle missing name parameter', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            const result = await generateMigrationOperation({});
+
+            expect(result.success).toBe(false);
+            expect(result.message).toContain('Migration name is required');
+            expect(result.migrationName).toBeUndefined();
+        });
+
+        it('should use custom output directory when specified', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+            const customDir = path.join(TEST_PROJECT_DIR, 'custom-migrations');
+            await fs.ensureDir(customDir);
+
+            try {
+                const result = await generateMigrationOperation({
+                    name: 'CustomDirTest',
+                    outputDir: customDir,
+                });
+
+                expect(result.success).toBe(true);
+
+                // Verify migration was created in custom directory
+                const files = await fs.readdir(customDir);
+                const migrationFile = files.find(f => f.includes('CustomDirTest'));
+                expect(migrationFile).toBeDefined();
+            } finally {
+                await fs.remove(customDir);
+            }
+        });
+
+        it('should handle TypeORM config errors gracefully', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            // Temporarily rename the vendure config to simulate a missing config
+            const configPath = path.join(TEST_PROJECT_DIR, 'src', 'vendure-config.ts');
+            const backupPath = path.join(TEST_PROJECT_DIR, 'src', 'vendure-config.backup.ts');
+            await fs.move(configPath, backupPath);
+
+            try {
+                const result = await generateMigrationOperation({ name: 'FailTest' });
+
+                expect(result.success).toBe(false);
+                expect(result.message).toBeDefined();
+                expect(result.migrationName).toBeUndefined();
+            } finally {
+                await fs.move(backupPath, configPath);
+            }
+        });
+    });
+
+    describe('runMigrationsOperation', () => {
+        it('should fail when not in a Vendure project directory', async () => {
+            process.chdir(__dirname);
+
+            const result = await runMigrationsOperation();
+
+            expect(result.success).toBe(false);
+            expect(result.message).toContain('Not in a Vendure project directory');
+            expect(result.migrationsRan).toBeUndefined();
+        });
+
+        it('should report no pending migrations when none exist', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            const result = await runMigrationsOperation();
+
+            expect(result.success).toBe(true);
+            expect(result.message).toContain('No pending migrations found');
+            expect(result.migrationsRan).toBeDefined();
+            expect(result.migrationsRan).toHaveLength(0);
+        });
+
+        it('should run pending migrations successfully', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            // First generate a migration
+            const generateResult = await generateMigrationOperation({
+                name: 'TestMigration',
+                outputDir: MIGRATIONS_DIR,
+            });
+            expect(generateResult.success).toBe(true);
+
+            // Then run migrations
+            const runResult = await runMigrationsOperation();
+
+            expect(runResult.success).toBe(true);
+            expect(runResult.message).toContain('Successfully ran');
+            expect(runResult.migrationsRan).toBeDefined();
+            expect(runResult.migrationsRan?.length).toBeGreaterThan(0);
+        });
+
+        it('should handle database connection errors gracefully', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            // Ensure a clean module state before mocking
+            vi.resetModules();
+
+            // Mock the loadVendureConfigFile helper to return a config with an invalid database path
+            vi.doMock('../src/commands/migrate/load-vendure-config-file', async () => {
+                const { config: realConfig }: { config: any } = await vi.importActual(
+                    path.join(TEST_PROJECT_DIR, 'src', 'vendure-config.ts'),
+                );
+
+                return {
+                    __esModule: true,
+                    loadVendureConfigFile: () =>
+                        Promise.resolve({
+                            ...realConfig,
+                            dbConnectionOptions: {
+                                ...realConfig.dbConnectionOptions,
+                                database: '/nonexistent/dir/test.db',
+                            },
+                        }),
+                };
+            });
+
+            // Re-import the operation after the mock so that it picks up the mocked helper
+            const { runMigrationsOperation: runMigrationsWithInvalidDb } = await import(
+                '../src/commands/migrate/migration-operations'
+            );
+
+            const result = await runMigrationsWithInvalidDb();
+
+            expect(result.success).toBe(false);
+            expect(result.message).toBeDefined();
+            expect(result.migrationsRan).toBeUndefined();
+
+            // Clean up mock for subsequent tests
+            vi.unmock('../src/commands/migrate/load-vendure-config-file');
+        });
+    });
+
+    describe('revertMigrationOperation', () => {
+        it('should fail when not in a Vendure project directory', async () => {
+            process.chdir(__dirname);
+
+            const result = await revertMigrationOperation();
+
+            expect(result.success).toBe(false);
+            expect(result.message).toContain('Not in a Vendure project directory');
+        });
+
+        it('should revert the last migration successfully', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            // Generate and run a migration first
+            const generateResult = await generateMigrationOperation({
+                name: 'RevertTest',
+                outputDir: MIGRATIONS_DIR,
+            });
+            expect(generateResult.success).toBe(true);
+
+            const runResult = await runMigrationsOperation();
+            expect(runResult.success).toBe(true);
+
+            // Now revert
+            const revertResult = await revertMigrationOperation();
+
+            expect(revertResult.success).toBe(true);
+            expect(revertResult.message).toBe('Successfully reverted last migration');
+        });
+
+        it('should handle no migrations to revert gracefully', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            // Try to revert when no migrations have been run
+            const result = await revertMigrationOperation();
+
+            // This might fail or succeed depending on TypeORM behavior
+            // The important thing is it doesn't throw and returns a structured result
+            expect(result).toHaveProperty('success');
+            expect(result).toHaveProperty('message');
+        });
+    });
+
+    describe('Integration scenarios', () => {
+        it('should handle a complete migration workflow', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            // 1. Generate first migration
+            const generate1 = await generateMigrationOperation({
+                name: 'InitialSchema',
+                outputDir: MIGRATIONS_DIR,
+            });
+            expect(generate1.success).toBe(true);
+
+            // 2. Run migrations
+            const run1 = await runMigrationsOperation();
+            expect(run1.success).toBe(true);
+
+            // Since there are no actual schema changes, migrations might be empty
+            // This is expected behavior
+
+            // 3. Generate second migration
+            const generate2 = await generateMigrationOperation({
+                name: 'AddColumns',
+                outputDir: MIGRATIONS_DIR,
+            });
+            expect(generate2.success).toBe(true);
+
+            // 4. Try to run migrations again
+            const run2 = await runMigrationsOperation();
+            expect(run2.success).toBe(true);
+
+            // Since no actual schema changes, this might have 0 migrations
+            // which is acceptable
+        });
+
+        it('should handle concurrent operations gracefully', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            // Try to run multiple operations concurrently
+            const operations = [
+                generateMigrationOperation({ name: 'Concurrent1', outputDir: MIGRATIONS_DIR }),
+                generateMigrationOperation({ name: 'Concurrent2', outputDir: MIGRATIONS_DIR }),
+                generateMigrationOperation({ name: 'Concurrent3', outputDir: MIGRATIONS_DIR }),
+            ];
+
+            const results = await Promise.all(operations);
+
+            // All should complete without throwing
+            results.forEach(result => {
+                expect(result).toHaveProperty('success');
+                expect(result).toHaveProperty('message');
+            });
+
+            // At least some should succeed
+            const successCount = results.filter(r => r.success).length;
+            expect(successCount).toBeGreaterThan(0);
+        });
+    });
+
+    describe('Error recovery', () => {
+        it('should recover from interrupted migration generation', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            // Create a partial migration file to simulate interruption
+            const partialFile = path.join(MIGRATIONS_DIR, '1234567890-Partial.ts');
+            await fs.writeFile(partialFile, 'export class Partial1234567890 {}');
+
+            // Should still be able to generate new migrations
+            const result = await generateMigrationOperation({
+                name: 'RecoveryTest',
+                outputDir: MIGRATIONS_DIR,
+            });
+
+            // The operation should complete successfully
+            expect(result.success).toBe(true);
+            expect(result.message).toBeDefined();
+        });
+
+        it('should provide helpful error messages for common issues', async () => {
+            process.chdir(TEST_PROJECT_DIR);
+
+            // Test various error scenarios
+            const scenarios = [
+                {
+                    name: 'generate without name',
+                    operation: () => generateMigrationOperation({}),
+                    expectedMessage: 'Migration name is required',
+                },
+                {
+                    name: 'invalid migration name',
+                    operation: () => generateMigrationOperation({ name: '123invalid' }),
+                    expectedMessage: 'must contain only letters, numbers, underscores and dashes',
+                },
+            ];
+
+            for (const scenario of scenarios) {
+                const result = await scenario.operation();
+                expect(result.success).toBe(false);
+                expect(result.message).toContain(scenario.expectedMessage);
+            }
+        });
+    });
+});

+ 31 - 0
packages/cli/e2e/vitest.e2e.config.mts

@@ -0,0 +1,31 @@
+import path from 'path';
+import swc from 'unplugin-swc';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+    plugins: [
+        // SWC required to support decorators used in test plugins
+        swc.vite({
+            jsc: {
+                transform: {
+                    useDefineForClassFields: false,
+                },
+            },
+        }) as any,
+    ],
+    test: {
+        include: ['e2e/**/*.e2e-spec.ts'],
+        globals: true,
+        environment: 'node',
+        testTimeout: 60000,
+        pool: 'forks',
+        poolOptions: {
+            forks: {
+                singleFork: true,
+            },
+        },
+        typecheck: {
+            tsconfig: path.join(__dirname, 'config/tsconfig.e2e.json'),
+        },
+    },
+});

+ 2 - 1
packages/cli/package.json

@@ -22,7 +22,8 @@
         "build": "rimraf dist && tsc -p ./tsconfig.cli.json && ts-node ./build.ts",
         "watch": "tsc -p ./tsconfig.cli.json --watch",
         "ci": "npm run build",
-        "test": "vitest --config vitest.config.mts --run"
+        "test": "vitest --config vitest.config.mts --run",
+        "e2e": "vitest --config e2e/vitest.e2e.config.mts --run"
     },
     "publishConfig": {
         "access": "public"

+ 270 - 0
packages/cli/src/README.md

@@ -0,0 +1,270 @@
+# Vendure CLI Command Structure
+
+This document describes the CLI command structure that supports both interactive and non-interactive modes, allowing for guided prompts during development and automated execution in CI/CD environments.
+
+## Overview
+
+The Vendure CLI supports two modes of operation:
+
+- **Interactive Mode**: Provides guided prompts and menus for easy use during development
+- **Non-Interactive Mode**: Allows direct command execution with arguments and options, perfect for scripting, CI/CD, and AI agents
+
+The CLI uses a structured approach where all commands are defined in an array of `CliCommandDefinition` objects, making it easy to add, remove, and modify commands.
+
+## Command Definition Interface
+
+```typescript
+interface CliCommandDefinition {
+    name: string;                    // The command name (e.g., 'add', 'migrate')
+    description: string;             // Command description shown in help
+    options?: CliCommandOption[];    // Optional array of command options
+    action: (options?: Record<string, any>) => Promise<void>; // Command implementation
+}
+```
+
+## Option Definition Interface
+
+```typescript
+interface CliCommandOption {
+    short?: string;                  // Short flag (e.g., '-p')
+    long: string;                    // Long flag (e.g., '--plugin <name>')
+    description: string;             // Option description
+    required?: boolean;              // Whether the option is required
+    subOptions?: CliCommandOption[]; // Sub-options for complex commands
+}
+```
+
+## Interactive vs Non-Interactive Mode Detection
+
+Commands automatically detect which mode to use based on provided options:
+
+```typescript
+// Non-interactive mode is triggered when any option has a truthy value (not false)
+const nonInteractive = options && Object.values(options).some(v => v !== undefined && v !== false);
+
+if (nonInteractive) {
+    await handleNonInteractiveMode(options);
+} else {
+    await handleInteractiveMode();
+}
+```
+
+## Available Commands
+
+### Add Command
+
+The `add` command supports both modes for adding features to your Vendure project.
+
+**Interactive Mode:**
+```bash
+npx vendure add
+```
+
+**Non-Interactive Mode:**
+```bash
+# Create a new plugin
+npx vendure add -p MyPlugin
+
+# Add an entity to a plugin
+npx vendure add -e MyEntity --selected-plugin MyPlugin
+
+# Add an entity with features
+npx vendure add -e MyEntity --selected-plugin MyPlugin --custom-fields --translatable
+
+# Add a service to a plugin
+npx vendure add -s MyService --selected-plugin MyPlugin
+
+# Add a service with specific type
+npx vendure add -s MyService --selected-plugin MyPlugin --type entity
+
+# Add job queue support to a plugin
+npx vendure add -j MyPlugin --name my-job --selected-service MyService
+
+# Add GraphQL codegen to a plugin
+npx vendure add -c MyPlugin
+
+# Add API extension to a plugin
+npx vendure add -a MyPlugin --queryName getCustomData --mutationName updateCustomData
+
+# Add UI extensions to a plugin
+npx vendure add -u MyPlugin
+```
+
+### Migrate Command
+
+The `migrate` command supports both modes for database migration management.
+
+**Interactive Mode:**
+```bash
+npx vendure migrate
+```
+
+**Non-Interactive Mode:**
+```bash
+# Generate a new migration
+npx vendure migrate -g my-migration-name
+
+# Run pending migrations
+npx vendure migrate -r
+
+# Revert the last migration
+npx vendure migrate --revert
+
+# Generate migration with custom output directory
+npx vendure migrate -g my-migration -o ./custom/migrations
+```
+
+## Command Implementation Patterns
+
+### Basic Command Structure
+
+```typescript
+{
+    name: 'add',
+    description: 'Add a feature to your Vendure project',
+    options: [
+        {
+            short: '-p',
+            long: '--plugin <name>',
+            description: 'Create a new plugin with the specified name',
+            required: false,
+        },
+        {
+            short: '-e',
+            long: '--entity <name>',
+            description: 'Add a new entity to a plugin',
+            required: false,
+        },
+        // ... more options
+    ],
+    action: async (options) => {
+        const { addCommand } = await import('./add/add');
+        await addCommand(options);
+        process.exit(0);
+    },
+}
+```
+
+### Command with Sub-Options
+
+```typescript
+{
+    short: '-j',
+    long: '--job-queue [plugin]',
+    description: 'Add job-queue support to the specified plugin',
+    required: false,
+    subOptions: [
+        {
+            long: '--name <name>',
+            description: 'Name for the job queue (required with -j)',
+            required: false,
+        },
+        {
+            long: '--selected-service <name>',
+            description: 'Name of the service to add the job queue to (required with -j)',
+            required: false,
+        },
+    ],
+},
+{
+    short: '-e',
+    long: '--entity <name>',
+    description: 'Add a new entity with the specified class name',
+    required: false,
+    subOptions: [
+        {
+            long: '--selected-plugin <name>',
+            description: 'Name of the plugin to add the entity to (required with -e)',
+            required: false,
+        },
+        {
+            long: '--custom-fields',
+            description: 'Add custom fields support to the entity',
+            required: false,
+        },
+        {
+            long: '--translatable',
+            description: 'Make the entity translatable',
+            required: false,
+        },
+    ],
+},
+{
+    short: '-s',
+    long: '--service <name>',
+    description: 'Add a new service with the specified class name',
+    required: false,
+    subOptions: [
+        {
+            long: '--selected-plugin <name>',
+            description: 'Name of the plugin to add the service to (required with -s)',
+            required: false,
+        },
+        {
+            long: '--type <type>',
+            description: 'Type of service: basic or entity (default: basic)',
+            required: false,
+        },
+    ],
+}
+```
+
+### Non-Interactive Mode Validation
+
+Commands implement validation for non-interactive mode to ensure all required parameters are provided.
+
+### Entity and Service Commands
+
+Entity and service commands now support non-interactive mode with the `--selected-plugin` parameter to specify the target plugin. Both commands support additional options for customization:
+
+- Entity commands support `--custom-fields` and `--translatable` flags
+- Service commands support `--type` parameter to specify service type (basic or entity)
+
+**Example Error Handling:**
+```bash
+$ npx vendure add -e MyEntity --selected-plugin NonExistentPlugin
+Error: Plugin "NonExistentPlugin" not found. Available plugins: MyActualPlugin, AnotherPlugin
+```
+
+## Interactive Mode Features
+
+### Timeout Protection
+
+Interactive prompts include timeout protection to prevent hanging in automated environments
+
+## Adding New Commands
+
+To add a new command, add it to the `cliCommands` array in `packages/cli/src/commands/command-declarations.ts`:
+
+```typescript
+export const cliCommands: CliCommandDefinition[] = [
+    // ... existing commands ...
+    {
+        name: 'new-command',
+        description: 'Description of the new command',
+        options: [
+            {
+                short: '-o',
+                long: '--option <value>',
+                description: 'Description of the option',
+                required: false,
+            },
+        ],
+        action: async (options) => {
+            const { newCommand } = await import('./new-command/new-command');
+            await newCommand(options);
+            process.exit(0);
+        },
+    },
+];
+```
+
+## File Structure
+
+- `packages/cli/src/shared/cli-command-definition.ts` - Interface definitions
+- `packages/cli/src/shared/command-registry.ts` - Command registration utility
+- `packages/cli/src/commands/command-declarations.ts` - Command declarations array
+- `packages/cli/src/commands/add/add.ts` - Add command implementation with dual mode support
+- `packages/cli/src/commands/migrate/migrate.ts` - Migrate command implementation with dual mode support
+- `packages/cli/src/utilities/utils.ts` - Utility functions including timeout protection
+- `packages/cli/src/cli.ts` - Main CLI entry point

+ 5 - 17
packages/cli/src/cli.ts

@@ -3,6 +3,9 @@
 import { Command } from 'commander';
 import pc from 'picocolors';
 
+import { cliCommands } from './commands/command-declarations';
+import { registerCommands } from './shared/command-registry';
+
 const program = new Command();
 
 // eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -24,22 +27,7 @@ Y88  88P 88888888 888  888 888  888 888  888 888    88888888
 `),
     );
 
-program
-    .command('add')
-    .description('Add a feature to your Vendure project')
-    .action(async () => {
-        const { addCommand } = await import('./commands/add/add');
-        await addCommand();
-        process.exit(0);
-    });
-
-program
-    .command('migrate')
-    .description('Generate, run or revert a database migration')
-    .action(async () => {
-        const { migrateCommand } = await import('./commands/migrate/migrate');
-        await migrateCommand();
-        process.exit(0);
-    });
+// Register all commands from the array
+registerCommands(program, cliCommands);
 
 void program.parseAsync(process.argv);

+ 258 - 0
packages/cli/src/commands/add/add-operations.ts

@@ -0,0 +1,258 @@
+import { addApiExtensionCommand } from './api-extension/add-api-extension';
+import { addCodegenCommand } from './codegen/add-codegen';
+import { addEntityCommand } from './entity/add-entity';
+import { addJobQueueCommand } from './job-queue/add-job-queue';
+import { createNewPluginCommand } from './plugin/create-new-plugin';
+import { addServiceCommand } from './service/add-service';
+import { addUiExtensionsCommand } from './ui-extensions/add-ui-extensions';
+
+// The set of mutually-exclusive operations that can be performed in a non-interactive call to the `add` command.
+export interface AddOperationOptions {
+    /** Create a new plugin with the given name */
+    plugin?: string;
+    /** Add a new entity class with the given name */
+    entity?: string;
+    /** Add a new service with the given name */
+    service?: string;
+    /** 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;
+    /** Name for the job queue (used with jobQueue) */
+    name?: string;
+    /** Name for the query (used with apiExtension) */
+    queryName?: string;
+    /** Name for the mutation (used with apiExtension) */
+    mutationName?: string;
+    /** Name of the service to use (used with jobQueue) */
+    selectedService?: string;
+    /** Selected plugin name for entity/service commands */
+    selectedPlugin?: string;
+    /** Add custom fields support to entity */
+    customFields?: boolean;
+    /** Make entity translatable */
+    translatable?: boolean;
+    /** Service type: basic or entity */
+    type?: string;
+    /** Selected entity name for entity service commands */
+    selectedEntity?: string;
+}
+
+export interface AddOperationResult {
+    success: boolean;
+    message: string;
+}
+
+/**
+ * Determines which sub-command to execute based on the provided options and
+ * delegates the work to that command's `run()` function. The interactive prompts
+ * inside the sub-command will only be shown for data that is still missing – so
+ * callers can supply as many or as few options as they need.
+ */
+export async function performAddOperation(options: AddOperationOptions): Promise<AddOperationResult> {
+    try {
+        // Figure out which flag was set. They are mutually exclusive: the first
+        // truthy option determines the sub-command we run.
+        if (options.plugin) {
+            // Validate that a plugin name was provided
+            if (typeof options.plugin !== 'string' || !options.plugin.trim()) {
+                throw new Error('Plugin name is required. Usage: vendure add -p <plugin-name>');
+            }
+            await createNewPluginCommand.run({ name: options.plugin, config: options.config });
+            return {
+                success: true,
+                message: `Plugin "${options.plugin}" created successfully`,
+            };
+        }
+        if (options.entity) {
+            // Validate that an entity name was provided
+            if (typeof options.entity !== 'string' || !options.entity.trim()) {
+                throw new Error(
+                    'Entity name is required. Usage: vendure add -e <entity-name> --selected-plugin <plugin-name>',
+                );
+            }
+            // Validate that a plugin name was provided for non-interactive mode
+            if (
+                !options.selectedPlugin ||
+                typeof options.selectedPlugin !== 'string' ||
+                !options.selectedPlugin.trim()
+            ) {
+                throw new Error(
+                    'Plugin name is required when running in non-interactive mode. Usage: vendure add -e <entity-name> --selected-plugin <plugin-name>',
+                );
+            }
+            // Pass the class name and plugin name with additional options
+            await addEntityCommand.run({
+                className: options.entity,
+                isNonInteractive: true,
+                config: options.config,
+                pluginName: options.selectedPlugin,
+                customFields: options.customFields,
+                translatable: options.translatable,
+            });
+            return {
+                success: true,
+                message: `Entity "${options.entity}" added successfully to plugin "${options.selectedPlugin}"`,
+            };
+        }
+        if (options.service) {
+            // Validate that a service name was provided
+            if (typeof options.service !== 'string' || !options.service.trim()) {
+                throw new Error(
+                    'Service name is required. Usage: vendure add -s <service-name> --selected-plugin <plugin-name>',
+                );
+            }
+            // Validate that a plugin name was provided for non-interactive mode
+            if (
+                !options.selectedPlugin ||
+                typeof options.selectedPlugin !== 'string' ||
+                !options.selectedPlugin.trim()
+            ) {
+                throw new Error(
+                    'Plugin name is required when running in non-interactive mode. Usage: vendure add -s <service-name> --selected-plugin <plugin-name>',
+                );
+            }
+            await addServiceCommand.run({
+                serviceName: options.service,
+                isNonInteractive: true,
+                config: options.config,
+                pluginName: options.selectedPlugin,
+                serviceType: options.selectedEntity ? 'entity' : options.type || 'basic',
+                selectedEntityName: options.selectedEntity,
+            });
+            return {
+                success: true,
+                message: `Service "${options.service}" added successfully to plugin "${options.selectedPlugin}"`,
+            };
+        }
+        if (options.jobQueue) {
+            const pluginName = typeof options.jobQueue === 'string' ? options.jobQueue : undefined;
+            // Validate required parameters for job queue
+            if (!options.name || typeof options.name !== 'string' || !options.name.trim()) {
+                throw new Error(
+                    'Job queue name is required. Usage: vendure add -j [plugin-name] --name <job-name>',
+                );
+            }
+            if (
+                !options.selectedService ||
+                typeof options.selectedService !== 'string' ||
+                !options.selectedService.trim()
+            ) {
+                throw new Error(
+                    'Service name is required for job queue. Usage: vendure add -j [plugin-name] --name <job-name> --selectedService <service-name>',
+                );
+            }
+            await addJobQueueCommand.run({
+                isNonInteractive: true,
+                config: options.config,
+                pluginName,
+                name: options.name,
+                selectedService: options.selectedService,
+            });
+            return {
+                success: true,
+                message: 'Job-queue feature added successfully',
+            };
+        }
+        if (options.codegen) {
+            const pluginName = typeof options.codegen === 'string' ? options.codegen : undefined;
+            // For codegen, if a boolean true is passed, plugin selection will be handled interactively
+            // If a string is passed, it should be a valid plugin name
+            if (typeof options.codegen === 'string' && !options.codegen.trim()) {
+                throw new Error(
+                    'Plugin name cannot be empty when specified. Usage: vendure add --codegen [plugin-name]',
+                );
+            }
+            await addCodegenCommand.run({
+                isNonInteractive: true,
+                config: options.config,
+                pluginName,
+            });
+            return {
+                success: true,
+                message: 'Codegen configuration added successfully',
+            };
+        }
+        if (options.apiExtension) {
+            const pluginName = typeof options.apiExtension === 'string' ? options.apiExtension : undefined;
+            // Validate that at least one of queryName or mutationName is provided and not empty
+            const hasValidQueryName =
+                options.queryName && typeof options.queryName === 'string' && options.queryName.trim() !== '';
+            const hasValidMutationName =
+                options.mutationName &&
+                typeof options.mutationName === 'string' &&
+                options.mutationName.trim() !== '';
+
+            if (!hasValidQueryName && !hasValidMutationName) {
+                throw new Error(
+                    'At least one of queryName or mutationName must be specified as a non-empty string. ' +
+                        'Usage: vendure add -a [plugin-name] --queryName <name> --mutationName <name>',
+                );
+            }
+
+            // If a string is passed for apiExtension, it should be a valid plugin name
+            if (typeof options.apiExtension === 'string' && !options.apiExtension.trim()) {
+                throw new Error(
+                    'Plugin name cannot be empty when specified. ' +
+                        'Usage: vendure add -a [plugin-name] --queryName <name> --mutationName <name>',
+                );
+            }
+
+            await addApiExtensionCommand.run({
+                isNonInteractive: true,
+                config: options.config,
+                pluginName,
+                queryName: options.queryName,
+                mutationName: options.mutationName,
+                selectedService: options.selectedService,
+            });
+            return {
+                success: true,
+                message: 'API extension scaffold added successfully',
+            };
+        }
+        if (options.uiExtensions) {
+            const pluginName = typeof options.uiExtensions === 'string' ? options.uiExtensions : undefined;
+            // For UI extensions, if a boolean true is passed, plugin selection will be handled interactively
+            // If a string is passed, it should be a valid plugin name
+            if (typeof options.uiExtensions === 'string' && !options.uiExtensions.trim()) {
+                throw new Error(
+                    'Plugin name cannot be empty when specified. Usage: vendure add --uiExtensions [plugin-name]',
+                );
+            }
+            await addUiExtensionsCommand.run({
+                isNonInteractive: true,
+                config: options.config,
+                pluginName,
+            });
+            return {
+                success: true,
+                message: 'UI extensions added successfully',
+            };
+        }
+
+        return {
+            success: false,
+            message: 'No valid add operation specified',
+        };
+    } catch (error: any) {
+        // Re-throw validation errors so they can be properly handled with stack trace
+        if (
+            error.message.includes('is required') ||
+            error.message.includes('cannot be empty') ||
+            error.message.includes('must be specified')
+        ) {
+            throw error;
+        }
+        return {
+            success: false,
+            message: error.message ?? 'Add operation failed',
+        };
+    }
+}

+ 57 - 8
packages/cli/src/commands/add/add.ts

@@ -3,8 +3,9 @@ import pc from 'picocolors';
 
 import { Messages } from '../../constants';
 import { CliCommand } from '../../shared/cli-command';
-import { pauseForPromptDisplay } from '../../utilities/utils';
+import { pauseForPromptDisplay, withInteractiveTimeout } from '../../utilities/utils';
 
+import { AddOperationOptions, performAddOperation } from './add-operations';
 import { addApiExtensionCommand } from './api-extension/add-api-extension';
 import { addCodegenCommand } from './codegen/add-codegen';
 import { addEntityCommand } from './entity/add-entity';
@@ -15,7 +16,51 @@ import { addUiExtensionsCommand } from './ui-extensions/add-ui-extensions';
 
 const cancelledMessage = 'Add feature cancelled.';
 
-export async function addCommand() {
+export interface AddOptions extends AddOperationOptions {}
+
+export async function addCommand(options?: AddOptions) {
+    // If any non-interactive option is supplied, we switch to the non-interactive path
+    const nonInteractive = options && Object.values(options).some(v => v !== undefined && v !== false);
+
+    if (nonInteractive) {
+        await handleNonInteractiveMode(options as AddOperationOptions);
+    } else {
+        await handleInteractiveMode();
+    }
+}
+
+async function handleNonInteractiveMode(options: AddOperationOptions) {
+    try {
+        const result = await performAddOperation(options);
+        if (result.success) {
+            log.success(result.message);
+        } else {
+            log.error(result.message);
+            process.exit(1);
+        }
+    } catch (e: any) {
+        // For validation errors, show the full error with stack trace
+        if (e.message.includes('Plugin name is required')) {
+            // Extract error message and stack trace
+            const errorMessage = e.message;
+            const stackLines = e.stack.split('\n');
+            const stackTrace = stackLines.slice(1).join('\n'); // Remove first line (error message)
+
+            // Display stack trace first, then colored error message at the end
+            log.error(stackTrace);
+            log.error(''); // Add empty line for better readability
+            log.error(pc.red('Error:') + ' ' + String(errorMessage));
+        } else {
+            log.error(e.message as string);
+            if (e.stack) {
+                log.error(e.stack);
+            }
+        }
+        process.exit(1);
+    }
+}
+
+async function handleInteractiveMode() {
     // eslint-disable-next-line no-console
     console.log(`\n`);
     intro(pc.blue("✨ Let's add a new feature to your Vendure project!"));
@@ -28,13 +73,17 @@ export async function addCommand() {
         addUiExtensionsCommand,
         addCodegenCommand,
     ];
-    const featureType = await select({
-        message: 'Which feature would you like to add?',
-        options: addCommands.map(c => ({
-            value: c.id,
-            label: `${pc.blue(`${c.category}`)} ${c.description}`,
-        })),
+
+    const featureType = await withInteractiveTimeout(async () => {
+        return await select({
+            message: 'Which feature would you like to add?',
+            options: addCommands.map(c => ({
+                value: c.id,
+                label: `${pc.blue(`${c.category}`)} ${c.description}`,
+            })),
+        });
     });
+
     if (isCancel(featureType)) {
         cancel(cancelledMessage);
         process.exit(0);

+ 128 - 23
packages/cli/src/commands/add/api-extension/add-api-extension.ts

@@ -4,7 +4,6 @@ import path from 'path';
 import {
     ClassDeclaration,
     CodeBlockWriter,
-    Expression,
     Node,
     Project,
     SourceFile,
@@ -16,8 +15,9 @@ import {
 
 import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { EntityRef } from '../../../shared/entity-ref';
+import { resolvePluginFromOptions } from '../../../shared/plugin-resolution';
 import { ServiceRef } from '../../../shared/service-ref';
-import { analyzeProject, selectPlugin, selectServiceRef } from '../../../shared/shared-prompts';
+import { analyzeProject, getServices, selectPlugin, selectServiceRef } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import {
     addImportsToFile,
@@ -25,11 +25,18 @@ import {
     customizeCreateUpdateInputInterfaces,
 } 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;
+    selectedService?: string;
 }
 
 export const addApiExtensionCommand = new CliCommand({
@@ -43,32 +50,130 @@ 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 serviceEntityRef = serviceRef.crudEntityRef;
-    const modifiedSourceFiles: SourceFile[] = [];
-    let resolver: ClassDeclaration | undefined;
-    let apiExtensions: VariableDeclaration | undefined;
+    const { project } = await analyzeProject({
+        providedVendurePlugin,
+        cancelledMessage,
+        config: options?.config,
+    });
+
+    const { plugin: resolvedPlugin, shouldPromptForSelection } = resolvePluginFromOptions(project, {
+        providedPlugin: providedVendurePlugin,
+        pluginName: options?.pluginName,
+        isNonInteractive: options?.isNonInteractive === true,
+    });
+
+    // In non-interactive mode, we need all required values upfront
+    if (options?.isNonInteractive) {
+        const hasValidQueryName = options?.queryName && options.queryName.trim() !== '';
+        const hasValidMutationName = options?.mutationName && options.mutationName.trim() !== '';
+
+        if (!hasValidQueryName && !hasValidMutationName) {
+            throw new Error(
+                'At least one of queryName or mutationName must be specified as a non-empty string in non-interactive mode.\n' +
+                    'Usage: npx vendure add -a <PluginName> --queryName <name> --mutationName <name>',
+            );
+        }
+    }
+
+    const plugin = resolvedPlugin ?? (await selectPlugin(project, cancelledMessage));
+
+    // NEW: Retrieve services of the plugin early so they can be used in both interactive and non-interactive flows
+    const services = getServices(project).filter(sr => {
+        return sr.classDeclaration
+            .getSourceFile()
+            .getDirectoryPath()
+            .includes(plugin.getSourceFile().getDirectoryPath());
+    });
+
+    let serviceRef: ServiceRef | undefined;
+    let serviceEntityRef: EntityRef | undefined;
 
     const scaffoldSpinner = spinner();
 
+    if (options?.isNonInteractive) {
+        // Validate that a service has been specified
+        if (!options.selectedService || options.selectedService.trim() === '') {
+            throw new Error(
+                'Service must be specified in non-interactive mode.\n' +
+                    'Usage: npx vendure add -a <PluginName> --queryName <name> --mutationName <name> --selectedService <service-name>',
+            );
+        }
+
+        const selectedService = services.find(sr => sr.name === options.selectedService);
+
+        if (!selectedService) {
+            const availableServices = services.map(sr => sr.name);
+            if (availableServices.length === 0) {
+                throw new Error(
+                    `No services found in plugin "${plugin.name}".\n` +
+                        'Please first create a service using: npx vendure add -s <ServiceName>',
+                );
+            }
+            throw new Error(
+                `Service "${options.selectedService}" not found in plugin "${plugin.name}". Available services:\n` +
+                    availableServices.map(name => `  - ${name}`).join('\n'),
+            );
+        }
+        serviceRef = selectedService;
+        log.info(`Using service: ${serviceRef.name}`);
+    }
+
+    // INTERACTIVE FLOW: If not in non-interactive mode, allow the user to select or create a service
+    if (!options?.isNonInteractive) {
+        if (services.length === 0) {
+            log.info("No services found in the selected plugin. Let's create one first!");
+            const result = await addServiceCommand.run({
+                plugin,
+            });
+            serviceRef = result.serviceRef;
+        } else {
+            serviceRef = await selectServiceRef(project, plugin);
+        }
+    }
+
+    if (!serviceRef) {
+        cancel(cancelledMessage);
+        process.exit(0);
+    }
+
+    const modifiedSourceFiles: SourceFile[] = [];
+
+    if (serviceRef.crudEntityRef) {
+        serviceEntityRef = serviceRef.crudEntityRef;
+    }
+
+    let resolver: ClassDeclaration;
+    let apiExtensions: VariableDeclaration | undefined;
+
     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 (options?.isNonInteractive) {
+            // Use provided values - we already validated at least one exists and is non-empty
+            queryName = options?.queryName && options.queryName.trim() !== '' ? options.queryName.trim() : '';
+            mutationName =
+                options?.mutationName && options.mutationName.trim() !== ''
+                    ? options.mutationName.trim()
+                    : '';
+        } 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 +183,7 @@ async function addApiExtension(
         resolver = createCrudResolver(project, plugin, serviceRef, serviceEntityRef);
         modifiedSourceFiles.push(resolver.getSourceFile());
     } else {
-        if (isCancel(queryName)) {
+        if (!options?.isNonInteractive && isCancel(queryName)) {
             cancel(cancelledMessage);
             process.exit(0);
         }

+ 18 - 6
packages/cli/src/commands/add/codegen/add-codegen.ts

@@ -1,9 +1,10 @@
-import { cancel, log, note, outro, spinner } from '@clack/prompts';
+import { cancel, log, note, spinner } from '@clack/prompts';
 import path from 'path';
 import { StructureKind } from 'ts-morph';
 
 import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { PackageJson } from '../../../shared/package-json-ref';
+import { resolvePluginFromOptions } from '../../../shared/plugin-resolution';
 import { analyzeProject, selectMultiplePluginClasses } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { getRelativeImportPath } from '../../../utilities/ast-utils';
@@ -13,6 +14,9 @@ import { CodegenConfigRef } from './codegen-config-ref';
 
 export interface AddCodegenOptions {
     plugin?: VendurePluginRef;
+    pluginName?: string;
+    config?: string;
+    isNonInteractive?: boolean;
 }
 
 export const addCodegenCommand = new CliCommand({
@@ -27,9 +31,17 @@ async function addCodegen(options?: AddCodegenOptions): Promise<CliCommandReturn
     const { project } = await analyzeProject({
         providedVendurePlugin,
         cancelledMessage: 'Add codegen cancelled',
+        config: options?.config,
     });
-    const plugins = providedVendurePlugin
-        ? [providedVendurePlugin]
+
+    const { plugin: resolvedPlugin, shouldPromptForSelection } = resolvePluginFromOptions(project, {
+        providedPlugin: providedVendurePlugin,
+        pluginName: options?.pluginName,
+        isNonInteractive: options?.isNonInteractive === true,
+    });
+
+    const plugins = resolvedPlugin
+        ? [resolvedPlugin]
         : await selectMultiplePluginClasses(project, 'Add codegen cancelled');
 
     const packageJson = new PackageJson(project);
@@ -76,10 +88,10 @@ async function addCodegen(options?: AddCodegenOptions): Promise<CliCommandReturn
     if (!rootDir) {
         throw new Error('Could not find the root directory of the project');
     }
-    for (const plugin of plugins) {
+    for (const pluginRef of plugins) {
         const relativePluginPath = getRelativeImportPath({
             from: rootDir,
-            to: plugin.classDeclaration.getSourceFile(),
+            to: pluginRef.classDeclaration.getSourceFile(),
         });
         const generatedTypesPath = `${path.dirname(relativePluginPath)}/gql/generated.ts`;
         codegenFile.addEntryToGeneratesObject({
@@ -88,7 +100,7 @@ async function addCodegen(options?: AddCodegenOptions): Promise<CliCommandReturn
             initializer: `{ plugins: ['typescript'] }`,
         });
 
-        if (plugin.hasUiExtensions()) {
+        if (pluginRef.hasUiExtensions()) {
             const uiExtensionsPath = `${path.dirname(relativePluginPath)}/ui`;
             codegenFile.addEntryToGeneratesObject({
                 name: `'${uiExtensionsPath}/gql/'`,

+ 73 - 20
packages/cli/src/commands/add/entity/add-entity.ts

@@ -8,8 +8,8 @@ import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { EntityRef } from '../../../shared/entity-ref';
 import { analyzeProject, selectPlugin } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
-import { createFile } from '../../../utilities/ast-utils';
-import { pauseForPromptDisplay } from '../../../utilities/utils';
+import { createFile, getPluginClasses } from '../../../utilities/ast-utils';
+import { pauseForPromptDisplay, withInteractiveTimeout } from '../../../utilities/utils';
 
 import { addEntityToPlugin } from './codemods/add-entity-to-plugin/add-entity-to-plugin';
 
@@ -24,6 +24,11 @@ export interface AddEntityOptions {
         customFields: boolean;
         translatable: boolean;
     };
+    config?: string;
+    isNonInteractive?: boolean;
+    pluginName?: string;
+    customFields?: boolean;
+    translatable?: boolean;
 }
 
 export const addEntityCommand = new CliCommand({
@@ -37,8 +42,35 @@ async function addEntity(
     options?: Partial<AddEntityOptions>,
 ): Promise<CliCommandReturnVal<{ entityRef: EntityRef }>> {
     const providedVendurePlugin = options?.plugin;
-    const { project } = await analyzeProject({ providedVendurePlugin, cancelledMessage });
-    const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, cancelledMessage));
+    const { project } = await analyzeProject({
+        providedVendurePlugin,
+        cancelledMessage,
+        config: options?.config,
+    });
+
+    let vendurePlugin = providedVendurePlugin;
+
+    // If pluginName is provided (from CLI), find the plugin by name
+    if (options?.pluginName && !vendurePlugin) {
+        const pluginClasses = getPluginClasses(project);
+        const pluginClass = pluginClasses.find((p: ClassDeclaration) => p.getName() === options.pluginName);
+        if (!pluginClass) {
+            const availablePlugins = pluginClasses.map((p: ClassDeclaration) => p.getName()).join(', ');
+            throw new Error(
+                `Plugin "${options.pluginName}" not found. Available plugins: ${availablePlugins}`,
+            );
+        }
+        vendurePlugin = new VendurePluginRef(pluginClass);
+    }
+
+    // In non-interactive mode with no plugin specified after checking for pluginName, we cannot proceed
+    if (options?.isNonInteractive && !vendurePlugin) {
+        throw new Error(
+            'Plugin must be specified when running in non-interactive mode. Usage: vendure add -e <entity-name> --selected-plugin <plugin-name>',
+        );
+    }
+
+    vendurePlugin = vendurePlugin ?? (await selectPlugin(project, cancelledMessage));
     const modifiedSourceFiles: SourceFile[] = [];
 
     const customEntityName = options?.className ?? (await getCustomEntityName(cancelledMessage));
@@ -48,6 +80,7 @@ async function addEntity(
         fileName: paramCase(customEntityName) + '.entity',
         translationFileName: paramCase(customEntityName) + '-translation.entity',
         features: await getFeatures(options),
+        config: options?.config,
     };
 
     const entitySpinner = spinner();
@@ -77,23 +110,43 @@ async function getFeatures(options?: Partial<AddEntityOptions>): Promise<AddEnti
     if (options?.features) {
         return options?.features;
     }
-    const features = await multiselect({
-        message: 'Entity features (use ↑, ↓, space to select)',
-        required: false,
-        initialValues: ['customFields'],
-        options: [
-            {
-                label: 'Custom fields',
-                value: 'customFields',
-                hint: 'Adds support for custom fields on this entity',
-            },
-            {
-                label: 'Translatable',
-                value: 'translatable',
-                hint: 'Adds support for localized properties on this entity',
-            },
-        ],
+
+    // Handle non-interactive mode with explicit feature flags
+    if (options?.isNonInteractive) {
+        return {
+            customFields: options?.customFields ?? false,
+            translatable: options?.translatable ?? false,
+        };
+    }
+
+    // Default features for non-interactive mode when not specified
+    if (options?.className && !options?.features) {
+        return {
+            customFields: true,
+            translatable: false,
+        };
+    }
+
+    const features = await withInteractiveTimeout(async () => {
+        return await multiselect({
+            message: 'Entity features (use ↑, ↓, space to select)',
+            required: false,
+            initialValues: ['customFields'],
+            options: [
+                {
+                    label: 'Custom fields',
+                    value: 'customFields',
+                    hint: 'Adds support for custom fields on this entity',
+                },
+                {
+                    label: 'Translatable',
+                    value: 'translatable',
+                    hint: 'Adds support for localized properties on this entity',
+                },
+            ],
+        });
     });
+
     if (isCancel(features)) {
         cancel(cancelledMessage);
         process.exit(0);

+ 56 - 0
packages/cli/src/commands/add/entity/codemods/add-entity-to-plugin/add-entity-to-plugin.spec.ts

@@ -46,4 +46,60 @@ describe('addEntityToPlugin', () => {
             expectedFileName: 'existing-entity-prop.expected',
         });
     });
+
+    it('throws error when entity class is null', () => {
+        const project = new Project({
+            manipulationSettings: defaultManipulationSettings,
+        });
+        project.addSourceFileAtPath(path.join(__dirname, 'fixtures', 'no-entity-prop.fixture.ts'));
+        const pluginClasses = getPluginClasses(project);
+        const vendurePlugin = new VendurePluginRef(pluginClasses[0]);
+
+        expect(() => addEntityToPlugin(vendurePlugin, null as any)).toThrow('Could not find entity class');
+    });
+
+    it('throws error when entity class is undefined', () => {
+        const project = new Project({
+            manipulationSettings: defaultManipulationSettings,
+        });
+        project.addSourceFileAtPath(path.join(__dirname, 'fixtures', 'no-entity-prop.fixture.ts'));
+        const pluginClasses = getPluginClasses(project);
+        const vendurePlugin = new VendurePluginRef(pluginClasses[0]);
+
+        expect(() => addEntityToPlugin(vendurePlugin, undefined as any)).toThrow(
+            'Could not find entity class',
+        );
+    });
+
+    it('adds entity and import to plugin successfully', () => {
+        const project = new Project({
+            manipulationSettings: defaultManipulationSettings,
+        });
+        project.addSourceFileAtPath(path.join(__dirname, 'fixtures', 'no-entity-prop.fixture.ts'));
+        const pluginClasses = getPluginClasses(project);
+        const entityTemplatePath = path.join(__dirname, '../../templates/entity.template.ts');
+        const entityFile = createFile(
+            project,
+            entityTemplatePath,
+            path.join(__dirname, 'fixtures', 'entity.ts'),
+        );
+        const entityClass = entityFile.getClass('ScaffoldEntity');
+        const vendurePlugin = new VendurePluginRef(pluginClasses[0]);
+
+        // Execute the function
+        addEntityToPlugin(vendurePlugin, entityClass!);
+
+        // Verify the plugin metadata was updated
+        const entities = vendurePlugin.getMetadataOptions().getProperty('entities');
+        expect(entities).toBeDefined();
+
+        // Verify the import was added
+        const sourceFile = pluginClasses[0].getSourceFile();
+        const importDeclaration = sourceFile
+            .getImportDeclarations()
+            .find(imp =>
+                imp.getNamedImports().some(namedImport => namedImport.getName() === 'ScaffoldEntity'),
+            );
+        expect(importDeclaration).toBeDefined();
+    });
 });

+ 91 - 18
packages/cli/src/commands/add/job-queue/add-job-queue.ts

@@ -3,15 +3,22 @@ import { camelCase, pascalCase } from 'change-case';
 import { Node, Scope } from 'ts-morph';
 
 import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
+import { resolvePluginFromOptions } from '../../../shared/plugin-resolution';
 import { ServiceRef } from '../../../shared/service-ref';
-import { analyzeProject, selectPlugin, selectServiceRef } from '../../../shared/shared-prompts';
+import { analyzeProject, getServices, selectPlugin, selectServiceRef } from '../../../shared/shared-prompts';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { addImportsToFile } from '../../../utilities/ast-utils';
+import { withInteractiveTimeout } from '../../../utilities/utils';
 
 const cancelledMessage = 'Add API extension cancelled';
 
 export interface AddJobQueueOptions {
     plugin?: VendurePluginRef;
+    pluginName?: string;
+    name?: string;
+    selectedService?: string;
+    config?: string;
+    isNonInteractive?: boolean;
 }
 
 export const addJobQueueCommand = new CliCommand({
@@ -25,21 +32,88 @@ 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 jobQueueName = await text({
-        message: 'What is the name of the job queue?',
-        initialValue: 'my-background-task',
-        validate: input => {
-            if (!/^[a-z][a-z-0-9]+$/.test(input)) {
-                return 'The job queue name must be lowercase and contain only letters, numbers and dashes';
-            }
-        },
+    const { project } = await analyzeProject({
+        providedVendurePlugin,
+        cancelledMessage,
+        config: options?.config,
+    });
+
+    const { plugin: resolvedPlugin, shouldPromptForSelection } = resolvePluginFromOptions(project, {
+        providedPlugin: providedVendurePlugin,
+        pluginName: options?.pluginName,
+        isNonInteractive: options?.isNonInteractive === true,
     });
 
-    if (isCancel(jobQueueName)) {
+    // In non-interactive mode, we need all required values upfront
+    if (options?.isNonInteractive) {
+        if (!options?.name) {
+            throw new Error(
+                'Job queue name must be specified in non-interactive mode.\n' +
+                    'Usage: npx vendure add -j <PluginName> --name <job-queue-name> --selected-service <service-name>',
+            );
+        }
+        if (!options?.selectedService) {
+            throw new Error(
+                'Service must be specified in non-interactive mode.\n' +
+                    'Usage: npx vendure add -j <PluginName> --name <job-queue-name> --selected-service <service-name>',
+            );
+        }
+    }
+
+    const plugin = resolvedPlugin ?? (await selectPlugin(project, cancelledMessage));
+
+    let serviceRef: ServiceRef | undefined;
+
+    if (options?.isNonInteractive) {
+        const existingServices = getServices(project).filter(sr => {
+            return sr.classDeclaration
+                .getSourceFile()
+                .getDirectoryPath()
+                .includes(plugin.getSourceFile().getDirectoryPath());
+        });
+
+        const selectedService = existingServices.find(sr => sr.name === options.selectedService);
+
+        if (!selectedService) {
+            const availableServices = existingServices.map(sr => sr.name);
+            if (availableServices.length === 0) {
+                throw new Error(
+                    `No services found in plugin "${plugin.name}".\n` +
+                        'Please first create a service using: npx vendure add -s <ServiceName>',
+                );
+            } else {
+                throw new Error(
+                    `Service "${options.selectedService as string}" not found in plugin "${plugin.name}". Available services:\n` +
+                        availableServices.map(name => `  - ${name}`).join('\n'),
+                );
+            }
+        }
+
+        serviceRef = selectedService;
+        log.info(`Using service: ${serviceRef.name}`);
+    } else {
+        serviceRef = await selectServiceRef(project, plugin);
+    }
+
+    if (!serviceRef) {
+        throw new Error('Service is required for job queue');
+    }
+
+    const jobQueueName =
+        options?.name ??
+        (await withInteractiveTimeout(async () => {
+            return await text({
+                message: 'What is the name of the job queue?',
+                initialValue: 'my-background-task',
+                validate: input => {
+                    if (!/^[a-z][a-z-0-9]+$/.test(input)) {
+                        return 'The job queue name must be lowercase and contain only letters, numbers and dashes';
+                    }
+                },
+            });
+        }));
+
+    if (!options?.isNonInteractive && isCancel(jobQueueName)) {
         cancel(cancelledMessage);
         process.exit(0);
     }
@@ -64,7 +138,7 @@ async function addJobQueue(
         type: 'JobQueueService',
     });
 
-    const jobQueuePropertyName = camelCase(jobQueueName) + 'Queue';
+    const jobQueuePropertyName = camelCase(jobQueueName as string) + 'Queue';
 
     serviceRef.classDeclaration.insertProperty(0, {
         name: jobQueuePropertyName,
@@ -75,7 +149,6 @@ async function addJobQueue(
     serviceRef.classDeclaration.addImplements('OnModuleInit');
     let onModuleInitMethod = serviceRef.classDeclaration.getMethod('onModuleInit');
     if (!onModuleInitMethod) {
-        // Add this after the constructor
         const constructor = serviceRef.classDeclaration.getConstructors()[0];
         const constructorChildIndex = constructor?.getChildIndex() ?? 0;
 
@@ -94,7 +167,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 +207,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 => {

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

@@ -9,7 +9,7 @@ import { analyzeProject } from '../../../shared/shared-prompts';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
 import { VendurePluginRef } from '../../../shared/vendure-plugin-ref';
 import { addImportsToFile, createFile, getPluginClasses } from '../../../utilities/ast-utils';
-import { pauseForPromptDisplay } from '../../../utilities/utils';
+import { pauseForPromptDisplay, withInteractiveTimeout } from '../../../utilities/utils';
 import { addApiExtensionCommand } from '../api-extension/add-api-extension';
 import { addCodegenCommand } from '../codegen/add-codegen';
 import { addEntityCommand } from '../entity/add-entity';
@@ -23,15 +23,24 @@ 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> {
+    // Validate that if a name is provided, it's actually a string
+    if (options.name !== undefined && (typeof options.name !== 'string' || !options.name.trim())) {
+        throw new Error('Plugin name is required. Usage: vendure add -p <plugin-name>');
+    }
+
+    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?',
@@ -52,29 +61,37 @@ 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.`;
-            }
-        },
-    });
 
-    if (isCancel(confirmation)) {
-        cancel(cancelledMessage);
-        process.exit(0);
+    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);
+        }
+        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 +100,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,
@@ -94,16 +117,19 @@ export async function createNewPlugin(): Promise<CliCommandReturnVal> {
     ];
     let allModifiedSourceFiles = [...modifiedSourceFiles];
     while (!done) {
-        const featureType = await select({
-            message: `Add features to ${options.name}?`,
-            options: [
-                { value: 'no', label: "[Finish] No, I'm done!" },
-                ...followUpCommands.map(c => ({
-                    value: c.id,
-                    label: `[${c.category}] ${c.description}`,
-                })),
-            ],
+        const featureType = await withInteractiveTimeout(async () => {
+            return await select({
+                message: `Add features to ${options.name ?? 'plugin'}?`,
+                options: [
+                    { value: 'no', label: "[Finish] No, I'm done!" },
+                    ...followUpCommands.map(c => ({
+                        value: c.id,
+                        label: `[${c.category}] ${c.description}`,
+                    })),
+                ],
+            });
         });
+
         if (isCancel(featureType)) {
             done = true;
         }

+ 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 & {

+ 99 - 38
packages/cli/src/commands/add/service/add-service.ts

@@ -13,8 +13,9 @@ import {
     addImportsToFile,
     createFile,
     customizeCreateUpdateInputInterfaces,
+    getPluginClasses,
 } from '../../../utilities/ast-utils';
-import { pauseForPromptDisplay } from '../../../utilities/utils';
+import { pauseForPromptDisplay, withInteractiveTimeout } from '../../../utilities/utils';
 import { addEntityCommand } from '../entity/add-entity';
 
 const cancelledMessage = 'Add service cancelled';
@@ -24,6 +25,11 @@ interface AddServiceOptions {
     type: 'basic' | 'entity';
     serviceName: string;
     entityRef?: EntityRef;
+    config?: string;
+    isNonInteractive?: boolean;
+    pluginName?: string;
+    serviceType?: string;
+    selectedEntityName?: string;
 }
 
 export const addServiceCommand = new CliCommand({
@@ -37,39 +43,91 @@ async function addService(
     providedOptions?: Partial<AddServiceOptions>,
 ): Promise<CliCommandReturnVal<{ serviceRef: ServiceRef }>> {
     const providedVendurePlugin = providedOptions?.plugin;
-    const { project } = await analyzeProject({ providedVendurePlugin, cancelledMessage });
-    const vendurePlugin = providedVendurePlugin ?? (await selectPlugin(project, 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 || providedOptions?.isNonInteractive;
+    if (isNonInteractive && !providedVendurePlugin && !providedOptions?.pluginName) {
+        throw new Error(
+            'Plugin must be specified when running in non-interactive mode. Use selectPlugin in interactive mode.',
+        );
+    }
+
+    let vendurePlugin = providedVendurePlugin;
+
+    // If pluginName is provided (from CLI), find the plugin by name
+    if (providedOptions?.pluginName && !vendurePlugin) {
+        const pluginClasses = getPluginClasses(project);
+        const pluginClass = pluginClasses.find(
+            (p: ClassDeclaration) => p.getName() === providedOptions.pluginName,
+        );
+        if (!pluginClass) {
+            const availablePlugins = pluginClasses.map((p: ClassDeclaration) => p.getName()).join(', ');
+            throw new Error(
+                `Plugin "${providedOptions.pluginName}" not found. Available plugins: ${availablePlugins}`,
+            );
+        }
+        vendurePlugin = new VendurePluginRef(pluginClass);
+    }
+
+    vendurePlugin = vendurePlugin ?? (await selectPlugin(project, cancelledMessage));
     const modifiedSourceFiles: SourceFile[] = [];
     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)) {
+        (providedOptions?.serviceType as 'basic' | 'entity') ??
+        (isNonInteractive
+            ? 'basic'
+            : await withInteractiveTimeout(async () => {
+                  return 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 (!isNonInteractive && isCancel(type)) {
         cancel('Cancelled');
         process.exit(0);
     }
     const options: AddServiceOptions = {
-        type: type as AddServiceOptions['type'],
-        serviceName: 'MyService',
+        type,
+        serviceName: providedOptions?.serviceName ?? 'MyService',
+        config: providedOptions?.config,
     };
     if (type === 'entity') {
         let entityRef: EntityRef;
-        try {
-            entityRef = await selectEntity(vendurePlugin);
-        } catch (e: any) {
-            if (e.message === Messages.NoEntitiesFound) {
-                log.info(`No entities found in plugin ${vendurePlugin.name}. Let's create one first.`);
-                const result = await addEntityCommand.run({ plugin: vendurePlugin });
-                entityRef = result.entityRef;
-                modifiedSourceFiles.push(...result.modifiedSourceFiles);
-            } else {
-                throw e;
+
+        // If selectedEntityName is provided (non-interactive mode), find the entity by name
+        if (providedOptions?.selectedEntityName && isNonInteractive) {
+            const entities = vendurePlugin.getEntities();
+            const foundEntity = entities.find(entity => entity.name === providedOptions.selectedEntityName);
+
+            if (!foundEntity) {
+                const availableEntities = entities.map(entity => entity.name).join(', ');
+                throw new Error(
+                    `Entity "${providedOptions.selectedEntityName}" not found in plugin "${vendurePlugin.name}". Available entities: ${availableEntities || 'none'}`,
+                );
+            }
+            entityRef = foundEntity;
+        } else {
+            // Interactive mode or no selectedEntityName provided
+            try {
+                entityRef = await selectEntity(vendurePlugin);
+            } catch (e: any) {
+                if (e.message === Messages.NoEntitiesFound) {
+                    log.info(`No entities found in plugin ${vendurePlugin.name}. Let's create one first.`);
+                    const result = await addEntityCommand.run({ plugin: vendurePlugin });
+                    entityRef = result.entityRef;
+                    modifiedSourceFiles.push(...result.modifiedSourceFiles);
+                } else {
+                    throw e;
+                }
             }
         }
         options.entityRef = entityRef;
@@ -81,25 +139,28 @@ 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 as string}`).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',

+ 160 - 0
packages/cli/src/commands/command-declarations.ts

@@ -0,0 +1,160 @@
+import { CliCommandDefinition } from '../shared/cli-command-definition';
+
+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>',
+                description: 'Create a new plugin with the specified name',
+                required: false,
+            },
+            {
+                short: '-e',
+                long: '--entity <name>',
+                description: 'Add a new entity with the specified class name',
+                required: false,
+                subOptions: [
+                    {
+                        long: '--selected-plugin <name>',
+                        description: 'Name of the plugin to add the entity to (required with -e)',
+                        required: false,
+                    },
+                    {
+                        long: '--custom-fields',
+                        description: 'Add custom fields support to the entity',
+                        required: false,
+                    },
+                    {
+                        long: '--translatable',
+                        description: 'Make the entity translatable',
+                        required: false,
+                    },
+                ],
+            },
+            {
+                short: '-s',
+                long: '--service <name>',
+                description: 'Add a new service with the specified class name',
+                required: false,
+                subOptions: [
+                    {
+                        long: '--selected-plugin <name>',
+                        description: 'Name of the plugin to add the service to (required with -s)',
+                        required: false,
+                    },
+                    {
+                        long: '--type <type>',
+                        description: 'Type of service: basic or entity (default: basic)',
+                        required: false,
+                    },
+                    {
+                        long: '--selected-entity <n>',
+                        description:
+                            'Name of the entity for entity service (automatically sets type to entity)',
+                        required: false,
+                    },
+                ],
+            },
+            {
+                short: '-j',
+                long: '--job-queue [plugin]',
+                description: 'Add job-queue support to the specified plugin',
+                required: false,
+                subOptions: [
+                    {
+                        long: '--name <name>',
+                        description: 'Name for the job queue (required with -j)',
+                        required: false,
+                    },
+                    {
+                        long: '--selected-service <name>',
+                        description: 'Name of the service to add the job queue to (required with -j)',
+                        required: false,
+                    },
+                ],
+            },
+            {
+                short: '-c',
+                long: '--codegen [plugin]',
+                description: 'Add GraphQL codegen configuration to the specified plugin',
+                required: false,
+            },
+            {
+                short: '-a',
+                long: '--api-extension [plugin]',
+                description: 'Add an API extension scaffold to the specified plugin',
+                required: false,
+                subOptions: [
+                    {
+                        long: '--queryName <name>',
+                        description: 'Name for the query (used with -a)',
+                        required: false,
+                    },
+                    {
+                        long: '--mutationName <name>',
+                        description: 'Name for the mutation (used with -a)',
+                        required: false,
+                    },
+                    {
+                        long: '--selected-service <name>',
+                        description: 'Name of the service to add the API extension to (required with -a)',
+                        required: false,
+                    },
+                ],
+            },
+            {
+                short: '-u',
+                long: '--ui-extensions [plugin]',
+                description: 'Add Admin UI extensions setup to the specified plugin',
+                required: false,
+            },
+        ],
+        action: async options => {
+            const { addCommand } = await import('./add/add');
+            await addCommand(options);
+            process.exit(0);
+        },
+    },
+    {
+        name: 'migrate',
+        description: 'Generate, run or revert a database migration',
+        options: [
+            {
+                short: '-g',
+                long: '--generate <name>',
+                description: 'Generate a new migration with the specified name',
+                required: false,
+            },
+            {
+                short: '-r',
+                long: '--run',
+                description: 'Run pending migrations',
+                required: false,
+            },
+            {
+                long: '--revert',
+                description: 'Revert the last migration',
+                required: false,
+            },
+            {
+                short: '-o',
+                long: '--output-dir <path>',
+                description: 'Output directory for generated migrations',
+                required: false,
+            },
+        ],
+        action: async options => {
+            const { migrateCommand } = await import('./migrate/migrate');
+            await migrateCommand(options);
+            process.exit(0);
+        },
+    },
+];

+ 35 - 26
packages/cli/src/commands/migrate/generate-migration/generate-migration.ts

@@ -1,12 +1,12 @@
-import { cancel, isCancel, log, multiselect, select, spinner, text } from '@clack/prompts';
+import { cancel, isCancel, log, select, spinner, text } from '@clack/prompts';
 import { unique } from '@vendure/common/lib/unique';
 import { generateMigration, VendureConfig } from '@vendure/core';
-import * as fs from 'fs-extra';
 import path from 'path';
 
 import { CliCommand, CliCommandReturnVal } from '../../../shared/cli-command';
 import { analyzeProject } from '../../../shared/shared-prompts';
 import { VendureConfigRef } from '../../../shared/vendure-config-ref';
+import { withInteractiveTimeout } from '../../../utilities/utils';
 import { loadVendureConfigFile } from '../load-vendure-config-file';
 
 const cancelledMessage = 'Generate migration cancelled';
@@ -23,16 +23,19 @@ async function runGenerateMigration(): Promise<CliCommandReturnVal> {
     const vendureConfig = new VendureConfigRef(project);
     log.info('Using VendureConfig from ' + vendureConfig.getPathRelativeToProjectRoot());
 
-    const name = await text({
-        message: 'Enter a meaningful name for the migration',
-        initialValue: '',
-        placeholder: 'add-custom-fields',
-        validate: input => {
-            if (!/^[a-zA-Z][a-zA-Z-_0-9]+$/.test(input)) {
-                return 'The plugin name must contain only letters, numbers, underscores and dashes';
-            }
-        },
+    const name = await withInteractiveTimeout(async () => {
+        return await text({
+            message: 'Enter a meaningful name for the migration',
+            initialValue: '',
+            placeholder: 'add-custom-fields',
+            validate: input => {
+                if (!/^[a-zA-Z][a-zA-Z-_0-9]+$/.test(input)) {
+                    return 'The plugin name must contain only letters, numbers, underscores and dashes';
+                }
+            },
+        });
     });
+
     if (isCancel(name)) {
         cancel(cancelledMessage);
         process.exit(0);
@@ -43,18 +46,21 @@ async function runGenerateMigration(): Promise<CliCommandReturnVal> {
     let migrationDir = migrationsDirs[0];
 
     if (migrationsDirs.length > 1) {
-        const migrationDirSelect = await select({
-            message: 'Migration file location',
-            options: migrationsDirs
-                .map(c => ({
-                    value: c,
-                    label: c,
-                }))
-                .concat({
-                    value: 'other',
-                    label: 'Other',
-                }),
+        const migrationDirSelect = await withInteractiveTimeout(async () => {
+            return await select({
+                message: 'Migration file location',
+                options: migrationsDirs
+                    .map(c => ({
+                        value: c,
+                        label: c,
+                    }))
+                    .concat({
+                        value: 'other',
+                        label: 'Other',
+                    }),
+            });
         });
+
         if (isCancel(migrationDirSelect)) {
             cancel(cancelledMessage);
             process.exit(0);
@@ -63,11 +69,14 @@ async function runGenerateMigration(): Promise<CliCommandReturnVal> {
     }
 
     if (migrationsDirs.length === 1 || migrationDir === 'other') {
-        const confirmation = await text({
-            message: 'Migration file location',
-            initialValue: migrationsDirs[0],
-            placeholder: '',
+        const confirmation = await withInteractiveTimeout(async () => {
+            return await text({
+                message: 'Migration file location',
+                initialValue: migrationsDirs[0],
+                placeholder: '',
+            });
         });
+
         if (isCancel(confirmation)) {
             cancel(cancelledMessage);
             process.exit(0);

+ 87 - 11
packages/cli/src/commands/migrate/migrate.ts

@@ -1,29 +1,102 @@
 import { cancel, intro, isCancel, log, outro, select } from '@clack/prompts';
 import pc from 'picocolors';
 
-import { generateMigrationCommand } from './generate-migration/generate-migration';
-import { revertMigrationCommand } from './revert-migration/revert-migration';
-import { runMigrationCommand } from './run-migration/run-migration';
+import { withInteractiveTimeout } from '../../utilities/utils';
+
+import {
+    generateMigrationOperation,
+    revertMigrationOperation,
+    runMigrationsOperation,
+} from './migration-operations';
 
 const cancelledMessage = 'Migrate cancelled.';
 
+export interface MigrateOptions {
+    generate?: string;
+    run?: boolean;
+    revert?: boolean;
+    outputDir?: string;
+}
+
 /**
  * This command is currently not exposed due to unresolved issues which I think are related to
  * peculiarities in loading ESM modules vs CommonJS modules. More time is needed to dig into
  * this before we expose this command in the cli.ts file.
  */
-export async function migrateCommand() {
+export async function migrateCommand(options?: MigrateOptions) {
+    // Check if any non-interactive options are provided
+    if (options?.generate || options?.run || options?.revert) {
+        // Non-interactive mode
+        await handleNonInteractiveMode(options);
+        return;
+    }
+
+    // Interactive mode (original behavior)
+    await handleInteractiveMode();
+}
+
+async function handleNonInteractiveMode(options: MigrateOptions) {
+    try {
+        process.env.VENDURE_RUNNING_IN_CLI = 'true';
+
+        if (options.generate) {
+            const result = await generateMigrationOperation({
+                name: options.generate,
+                outputDir: options.outputDir,
+            });
+
+            if (result.success) {
+                log.success(result.message);
+            } else {
+                log.error(result.message);
+                process.exit(1);
+            }
+        } else if (options.run) {
+            const result = await runMigrationsOperation();
+
+            if (result.success) {
+                log.success(result.message);
+            } else {
+                log.error(result.message);
+                process.exit(1);
+            }
+        } else if (options.revert) {
+            const result = await revertMigrationOperation();
+
+            if (result.success) {
+                log.success(result.message);
+            } else {
+                log.error(result.message);
+                process.exit(1);
+            }
+        }
+
+        process.env.VENDURE_RUNNING_IN_CLI = undefined;
+    } catch (e: any) {
+        log.error(e.message as string);
+        if (e.stack) {
+            log.error(e.stack);
+        }
+        process.exit(1);
+    }
+}
+
+async function handleInteractiveMode() {
     // eslint-disable-next-line no-console
     console.log(`\n`);
     intro(pc.blue('🛠️️ Vendure migrations'));
-    const action = await select({
-        message: 'What would you like to do?',
-        options: [
-            { value: 'generate', label: 'Generate a new migration' },
-            { value: 'run', label: 'Run pending migrations' },
-            { value: 'revert', label: 'Revert the last migration' },
-        ],
+
+    const action = await withInteractiveTimeout(async () => {
+        return await select({
+            message: 'What would you like to do?',
+            options: [
+                { value: 'generate', label: 'Generate a new migration' },
+                { value: 'run', label: 'Run pending migrations' },
+                { value: 'revert', label: 'Revert the last migration' },
+            ],
+        });
     });
+
     if (isCancel(action)) {
         cancel(cancelledMessage);
         process.exit(0);
@@ -31,12 +104,15 @@ export async function migrateCommand() {
     try {
         process.env.VENDURE_RUNNING_IN_CLI = 'true';
         if (action === 'generate') {
+            const { generateMigrationCommand } = await import('./generate-migration/generate-migration');
             await generateMigrationCommand.run();
         }
         if (action === 'run') {
+            const { runMigrationCommand } = await import('./run-migration/run-migration');
             await runMigrationCommand.run();
         }
         if (action === 'revert') {
+            const { revertMigrationCommand } = await import('./revert-migration/revert-migration');
             await revertMigrationCommand.run();
         }
         outro('✅ Done!');

+ 299 - 0
packages/cli/src/commands/migrate/migration-operations.spec.ts

@@ -0,0 +1,299 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import {
+    generateMigrationOperation,
+    MigrationOptions,
+    MigrationResult,
+    revertMigrationOperation,
+    runMigrationsOperation,
+} from './migration-operations';
+
+describe('migration-operations', () => {
+    let defaultOptions: MigrationOptions;
+
+    beforeEach(() => {
+        defaultOptions = {
+            name: 'test-migration',
+            outputDir: './test-migrations',
+        };
+    });
+
+    describe('generateMigrationOperation()', () => {
+        it('Returns error when not in Vendure project directory', async () => {
+            const options: MigrationOptions = {};
+
+            const result = await generateMigrationOperation(options);
+
+            expect(result.success).toBe(false);
+            expect(result.message).toContain('Not in a Vendure project directory');
+            expect(result.migrationName).toBeUndefined();
+        });
+
+        it('Returns error with project validation for any name', async () => {
+            const options: MigrationOptions = {
+                name: 'test-migration',
+            };
+
+            const result = await generateMigrationOperation(options);
+
+            expect(result.success).toBe(false);
+            expect(result.message).toContain('Not in a Vendure project directory');
+            expect(result.migrationName).toBeUndefined();
+        });
+
+        it('Returns project error for valid migration names', async () => {
+            const validNames = [
+                'testMigration',
+                'test_migration',
+                'test-migration',
+                'TestMigration123',
+                'migration1',
+                'a',
+                'A123_test-name',
+            ];
+
+            for (const name of validNames) {
+                const options: MigrationOptions = { name };
+
+                const result = await generateMigrationOperation(options);
+                // All should fail with project validation error since we're not in a Vendure project
+                expect(result.success).toBe(false);
+                expect(result.message).toContain('Not in a Vendure project directory');
+            }
+        });
+
+        it('Returns project error for invalid migration names', async () => {
+            const invalidNames = [
+                '123invalid', // starts with number
+                'test migration', // contains space
+                'test@migration', // contains @
+                'test.migration', // contains .
+                'test/migration', // contains /
+                'test\\migration', // contains \
+                'test(migration)', // contains parentheses
+                '', // empty string
+                'test!migration', // contains !
+            ];
+
+            for (const name of invalidNames) {
+                const options: MigrationOptions = { name };
+
+                const result = await generateMigrationOperation(options);
+
+                expect(result.success).toBe(false);
+                // Project validation happens before name validation
+                expect(result.message).toContain('Not in a Vendure project directory');
+            }
+        });
+
+        it('Returns project error when outputDir is specified', async () => {
+            const options: MigrationOptions = {
+                name: 'valid-name',
+                outputDir: './custom-output',
+            };
+
+            const result = await generateMigrationOperation(options);
+
+            expect(result.success).toBe(false);
+            expect(result.message).toContain('Not in a Vendure project directory');
+        });
+
+        it('Returns error when migration name is missing', async () => {
+            const options: MigrationOptions = {};
+
+            const result = await generateMigrationOperation(options);
+
+            expect(result.success).toBe(false);
+            expect(result.message).toContain('Not in a Vendure project directory');
+            expect(result.migrationName).toBeUndefined();
+        });
+
+        it('Rejects migration names with invalid format', async () => {
+            const invalidNames = ['123invalid', 'test migration', 'test@migration', '', 'test!migration'];
+
+            for (const name of invalidNames) {
+                const result = await generateMigrationOperation({ name });
+
+                expect(result.success).toBe(false);
+                // Note: All will fail with project validation in test environment,
+                // but this tests that the function handles invalid names correctly
+                expect(result.message).toBeDefined();
+                expect(result.migrationName).toBeUndefined();
+            }
+        });
+
+        it('Accepts migration names with valid format', async () => {
+            const validNames = ['testMigration', 'test_migration', 'test-migration', 'A123_test-name'];
+
+            for (const name of validNames) {
+                const result = await generateMigrationOperation({ name });
+
+                // All will fail with project validation in test environment,
+                // but this verifies the function accepts valid name formats
+                expect(result.success).toBe(false);
+                expect(result.message).toContain('Not in a Vendure project directory');
+                expect(result.migrationName).toBeUndefined();
+            }
+        });
+
+        it('Returns MigrationResult with correct structure on error', async () => {
+            const options: MigrationOptions = {
+                name: 'valid-name',
+            };
+
+            const result = await generateMigrationOperation(options);
+
+            expect(result).toHaveProperty('success');
+            expect(result).toHaveProperty('message');
+            expect(typeof result.success).toBe('boolean');
+            expect(typeof result.message).toBe('string');
+
+            if (result.success) {
+                expect(result).toHaveProperty('migrationName');
+            }
+        });
+    });
+
+    describe('runMigrationsOperation()', () => {
+        it('Returns MigrationResult with correct structure', async () => {
+            const result = await runMigrationsOperation();
+
+            expect(result).toHaveProperty('success');
+            expect(result).toHaveProperty('message');
+            expect(typeof result.success).toBe('boolean');
+            expect(typeof result.message).toBe('string');
+
+            if (result.success) {
+                expect(result).toHaveProperty('migrationsRan');
+                expect(Array.isArray(result.migrationsRan)).toBe(true);
+            }
+        });
+
+        it('Handles error conditions gracefully', async () => {
+            const result = await runMigrationsOperation();
+
+            if (!result.success) {
+                expect(result.message).toBeDefined();
+                expect(result.message.length).toBeGreaterThan(0);
+                expect(result.migrationsRan).toBeUndefined();
+            }
+        });
+
+        it('Returns appropriate message structure', async () => {
+            const result = await runMigrationsOperation();
+
+            expect(result.message).toBeDefined();
+            expect(typeof result.message).toBe('string');
+
+            if (result.success && result.migrationsRan) {
+                if (result.migrationsRan.length > 0) {
+                    expect(result.message).toContain('Successfully ran');
+                    expect(result.message).toContain('migrations');
+                } else {
+                    expect(result.message).toContain('No pending migrations found');
+                }
+            }
+        });
+    });
+
+    describe('revertMigrationOperation()', () => {
+        it('Returns MigrationResult with correct structure', async () => {
+            const result = await revertMigrationOperation();
+
+            expect(result).toHaveProperty('success');
+            expect(result).toHaveProperty('message');
+            expect(typeof result.success).toBe('boolean');
+            expect(typeof result.message).toBe('string');
+        });
+
+        it('Handles error conditions gracefully', async () => {
+            const result = await revertMigrationOperation();
+
+            if (!result.success) {
+                expect(result.message).toBeDefined();
+                expect(result.message.length).toBeGreaterThan(0);
+            }
+        });
+
+        it('Returns success message when operation succeeds', async () => {
+            const result = await revertMigrationOperation();
+
+            if (result.success) {
+                expect(result.message).toBe('Successfully reverted last migration');
+            } else {
+                // When failing, should contain error information
+                expect(result.message).toBeDefined();
+                expect(result.message.length).toBeGreaterThan(0);
+            }
+        });
+
+        it('Does not include migration-specific properties on revert', async () => {
+            const result = await revertMigrationOperation();
+
+            // Revert operation should not return migrationName or migrationsRan
+            expect(result.migrationName).toBeUndefined();
+            expect(result.migrationsRan).toBeUndefined();
+        });
+    });
+
+    describe('MigrationResult interface compliance', () => {
+        function validateMigrationResult(result: MigrationResult) {
+            expect(result).toHaveProperty('success');
+            expect(result).toHaveProperty('message');
+            expect(typeof result.success).toBe('boolean');
+            expect(typeof result.message).toBe('string');
+
+            // Optional properties should be undefined or of correct type
+            if (result.migrationName !== undefined) {
+                expect(typeof result.migrationName).toBe('string');
+            }
+            if (result.migrationsRan !== undefined) {
+                expect(Array.isArray(result.migrationsRan)).toBe(true);
+                result.migrationsRan.forEach(migration => {
+                    expect(typeof migration).toBe('string');
+                });
+            }
+        }
+
+        it('generateMigrationOperation returns compliant MigrationResult', async () => {
+            const result = await generateMigrationOperation({ name: 'test' });
+            validateMigrationResult(result);
+        });
+
+        it('runMigrationsOperation returns compliant MigrationResult', async () => {
+            const result = await runMigrationsOperation();
+            validateMigrationResult(result);
+        });
+
+        it('revertMigrationOperation returns compliant MigrationResult', async () => {
+            const result = await revertMigrationOperation();
+            validateMigrationResult(result);
+        });
+    });
+
+    describe('Error handling consistency', () => {
+        it('All operations handle missing project context similarly', async () => {
+            const generateResult = await generateMigrationOperation({ name: 'test' });
+            const runResult = await runMigrationsOperation();
+            const revertResult = await revertMigrationOperation();
+
+            // All should fail in similar way when not in a Vendure project
+            expect(generateResult.success).toBe(false);
+            expect(runResult.success).toBe(false);
+            expect(revertResult.success).toBe(false);
+
+            // All should provide meaningful error messages
+            expect(generateResult.message.length).toBeGreaterThan(0);
+            expect(runResult.message.length).toBeGreaterThan(0);
+            expect(revertResult.message.length).toBeGreaterThan(0);
+        });
+
+        it('Operations return structured errors rather than throwing', async () => {
+            // These should not throw but return error results
+            await expect(generateMigrationOperation({ name: 'test' })).resolves.toBeDefined();
+            await expect(runMigrationsOperation()).resolves.toBeDefined();
+            await expect(revertMigrationOperation()).resolves.toBeDefined();
+        });
+    });
+});

+ 148 - 0
packages/cli/src/commands/migrate/migration-operations.ts

@@ -0,0 +1,148 @@
+import { log } from '@clack/prompts';
+import { generateMigration, revertLastMigration, runMigrations, VendureConfig } from '@vendure/core';
+import path from 'path';
+
+import { validateVendureProjectDirectory } from '../../shared/project-validation';
+import { analyzeProject } from '../../shared/shared-prompts';
+import { VendureConfigRef } from '../../shared/vendure-config-ref';
+
+import { loadVendureConfigFile } from './load-vendure-config-file';
+
+export interface MigrationOptions {
+    name?: string;
+    outputDir?: string;
+}
+
+export interface MigrationResult {
+    success: boolean;
+    message: string;
+    migrationName?: string;
+    migrationsRan?: string[];
+}
+
+export async function generateMigrationOperation(options: MigrationOptions = {}): Promise<MigrationResult> {
+    try {
+        validateVendureProjectDirectory();
+
+        const { project, tsConfigPath } = await analyzeProject({ cancelledMessage: '' });
+        const vendureConfig = new VendureConfigRef(project);
+        log.info('Using VendureConfig from ' + vendureConfig.getPathRelativeToProjectRoot());
+
+        const name = options.name;
+        if (!name) {
+            return {
+                success: false,
+                message: 'Migration name is required',
+            };
+        }
+
+        if (!/^[a-zA-Z][a-zA-Z-_0-9]+$/.test(name)) {
+            return {
+                success: false,
+                message: 'The migration name must contain only letters, numbers, underscores and dashes',
+            };
+        }
+
+        const config = await loadVendureConfigFile(vendureConfig, tsConfigPath);
+        const migrationsDirs = getMigrationsDir(vendureConfig, config);
+        const migrationDir = options.outputDir || migrationsDirs[0];
+
+        log.info('Generating migration...');
+        const migrationName = await generateMigration(config, { name, outputDir: migrationDir });
+
+        const report =
+            typeof migrationName === 'string'
+                ? `New migration generated: ${migrationName}`
+                : 'No changes in database schema were found, so no migration was generated';
+
+        return {
+            success: true,
+            message: report,
+            migrationName: typeof migrationName === 'string' ? migrationName : undefined,
+        };
+    } catch (error: any) {
+        return {
+            success: false,
+            message: error.message || 'Failed to generate migration',
+        };
+    }
+}
+
+export async function runMigrationsOperation(): Promise<MigrationResult> {
+    try {
+        validateVendureProjectDirectory();
+
+        const { project } = await analyzeProject({ cancelledMessage: '' });
+        const vendureConfig = new VendureConfigRef(project);
+        log.info('Using VendureConfig from ' + vendureConfig.getPathRelativeToProjectRoot());
+        const config = await loadVendureConfigFile(vendureConfig);
+
+        log.info('Running migrations...');
+        const migrationsRan = await runMigrations(config);
+
+        const report = migrationsRan.length
+            ? `Successfully ran ${migrationsRan.length} migrations`
+            : 'No pending migrations found';
+
+        return {
+            success: true,
+            message: report,
+            migrationsRan,
+        };
+    } catch (error: any) {
+        return {
+            success: false,
+            message: error.message || 'Failed to run migrations',
+        };
+    }
+}
+
+export async function revertMigrationOperation(): Promise<MigrationResult> {
+    try {
+        validateVendureProjectDirectory();
+
+        const { project } = await analyzeProject({ cancelledMessage: '' });
+        const vendureConfig = new VendureConfigRef(project);
+        log.info('Using VendureConfig from ' + vendureConfig.getPathRelativeToProjectRoot());
+        const config = await loadVendureConfigFile(vendureConfig);
+
+        log.info('Reverting last migration...');
+        await revertLastMigration(config);
+
+        return {
+            success: true,
+            message: 'Successfully reverted last migration',
+        };
+    } catch (error: any) {
+        return {
+            success: false,
+            message: error.message || 'Failed to revert migration',
+        };
+    }
+}
+
+function getMigrationsDir(vendureConfigRef: VendureConfigRef, config: VendureConfig): string[] {
+    const options: string[] = [];
+    if (
+        Array.isArray(config.dbConnectionOptions.migrations) &&
+        config.dbConnectionOptions.migrations.length
+    ) {
+        const firstEntry = config.dbConnectionOptions.migrations[0];
+        if (typeof firstEntry === 'string') {
+            options.push(path.dirname(firstEntry));
+        }
+    }
+    const migrationFile = vendureConfigRef.sourceFile
+        .getProject()
+        .getSourceFiles()
+        .find(sf => {
+            return sf
+                .getClasses()
+                .find(c => c.getImplements().find(i => i.getText() === 'MigrationInterface'));
+        });
+    if (migrationFile) {
+        options.push(migrationFile.getDirectory().getPath());
+    }
+    options.push(path.join(vendureConfigRef.sourceFile.getDirectory().getPath(), '../migrations'));
+    return options.map(p => path.normalize(p));
+}

+ 19 - 0
packages/cli/src/shared/cli-command-definition.ts

@@ -0,0 +1,19 @@
+export interface CliCommandOption {
+    long: string;
+    short?: string;
+    description: string;
+    required?: boolean;
+    defaultValue?: any;
+    subOptions?: CliCommandOption[]; // Options that are only valid when this option is used
+}
+
+export interface CliCommandDefinition {
+    name: string;
+    description: string;
+    options?: CliCommandOption[];
+    action: (options?: Record<string, any>) => Promise<void>;
+}
+
+export interface CliCommandConfig {
+    commands: CliCommandDefinition[];
+}

+ 50 - 0
packages/cli/src/shared/command-registry.ts

@@ -0,0 +1,50 @@
+import { Command } from 'commander';
+
+import { CliCommandDefinition, CliCommandOption } from './cli-command-definition';
+
+export function registerCommands(program: Command, commands: CliCommandDefinition[]): void {
+    commands.forEach(commandDef => {
+        const command = program
+            .command(commandDef.name)
+            .description(commandDef.description)
+            .action(async options => {
+                await commandDef.action(options);
+            });
+
+        // Add options if they exist
+        if (commandDef.options) {
+            commandDef.options.forEach(option => {
+                addOption(command, option);
+
+                // Add sub-options if they exist
+                if (option.subOptions) {
+                    option.subOptions.forEach(subOption => {
+                        // Create a version of the sub-option with indented description
+                        const indentedSubOption = {
+                            ...subOption,
+                            description: `  └─ ${subOption.description}`,
+                        };
+                        addOption(command, indentedSubOption);
+                    });
+                }
+            });
+        }
+    });
+}
+
+function addOption(command: Command, option: CliCommandOption): void {
+    const parts: string[] = [];
+    if (option.short) {
+        parts.push(option.short);
+    }
+    parts.push(option.long);
+
+    let optionString = parts.join(', ');
+
+    // Handle optional options which expect a value by converting <value> to [value]
+    if (!option.required) {
+        optionString = optionString.replace(/<([^>]+)>/g, '[$1]');
+    }
+
+    command.option(optionString, option.description, option.defaultValue);
+}

+ 51 - 0
packages/cli/src/shared/plugin-resolution.ts

@@ -0,0 +1,51 @@
+import { Project } from 'ts-morph';
+
+import { getPluginClasses } from '../utilities/ast-utils';
+
+import { VendurePluginRef } from './vendure-plugin-ref';
+
+export interface PluginResolutionOptions {
+    providedPlugin?: VendurePluginRef;
+    pluginName?: string;
+    isNonInteractive?: boolean;
+}
+
+export interface PluginResolutionResult {
+    plugin: VendurePluginRef | undefined;
+    shouldPromptForSelection: boolean;
+}
+
+/**
+ * Resolves a plugin reference from provided options, handling both interactive and non-interactive modes.
+ * This function centralizes the common plugin resolution logic used across multiple CLI commands.
+ */
+export function resolvePluginFromOptions(
+    project: Project,
+    options: PluginResolutionOptions,
+): PluginResolutionResult {
+    let plugin: VendurePluginRef | undefined = options.providedPlugin;
+
+    if (!plugin && options.pluginName) {
+        const pluginClasses = getPluginClasses(project);
+        const foundPlugin = pluginClasses.find(p => p.getName() === options.pluginName);
+
+        if (!foundPlugin) {
+            const availablePlugins = pluginClasses.map(p => p.getName()).filter(Boolean);
+            throw new Error(
+                `Plugin "${options.pluginName}" not found. Available plugins:\n` +
+                    availablePlugins.map(name => `  - ${name as string}`).join('\n'),
+            );
+        }
+
+        plugin = new VendurePluginRef(foundPlugin);
+    }
+
+    if (options.isNonInteractive && !plugin) {
+        throw new Error('Plugin must be specified when running in non-interactive mode');
+    }
+
+    return {
+        plugin,
+        shouldPromptForSelection: !plugin && !options.isNonInteractive,
+    };
+}

+ 41 - 0
packages/cli/src/shared/project-validation.ts

@@ -0,0 +1,41 @@
+import * as fs from 'fs-extra';
+import path from 'path';
+
+/**
+ * Checks if the current working directory is a valid Vendure project directory.
+ * This function centralizes the project validation logic used across CLI commands.
+ */
+export function isVendureProjectDirectory(): boolean {
+    const cwd = process.cwd();
+
+    const hasPackageJson = fs.existsSync(path.join(cwd, 'package.json'));
+    const hasVendureConfig =
+        fs.existsSync(path.join(cwd, 'vendure-config.ts')) ||
+        fs.existsSync(path.join(cwd, 'vendure-config.js')) ||
+        fs.existsSync(path.join(cwd, 'src/vendure-config.ts')) ||
+        fs.existsSync(path.join(cwd, 'src/vendure-config.js'));
+
+    if (hasPackageJson) {
+        try {
+            const packageJson = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
+            const dependencies = { ...packageJson.dependencies, ...packageJson.devDependencies };
+            const hasVendureDeps = Object.keys(dependencies).some(
+                dep => dep.includes('@vendure/') || dep === 'vendure',
+            );
+
+            return hasVendureDeps && hasVendureConfig;
+        } catch {
+            return false;
+        }
+    }
+
+    return false;
+}
+
+export function validateVendureProjectDirectory(): void {
+    if (!isVendureProjectDirectory()) {
+        throw new Error(
+            'Error: Not in a Vendure project directory. Please run this command from your Vendure project root.',
+        );
+    }
+}

+ 89 - 60
packages/cli/src/shared/shared-prompts.ts

@@ -4,7 +4,7 @@ import { ClassDeclaration, Project } from 'ts-morph';
 import { addServiceCommand } from '../commands/add/service/add-service';
 import { Messages } from '../constants';
 import { getPluginClasses, getTsMorphProject, selectTsConfigFile } from '../utilities/ast-utils';
-import { pauseForPromptDisplay } from '../utilities/utils';
+import { pauseForPromptDisplay, withInteractiveTimeout } from '../utilities/utils';
 
 import { EntityRef } from './entity-ref';
 import { ServiceRef } from './service-ref';
@@ -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> {
@@ -37,14 +47,18 @@ export async function selectPlugin(project: Project, cancelledMessage: string):
         cancel(Messages.NoPluginsFound);
         process.exit(0);
     }
-    const targetPlugin = await select({
-        message: 'To which plugin would you like to add the feature?',
-        options: pluginClasses.map(c => ({
-            value: c,
-            label: c.getName() as string,
-        })),
-        maxItems: 10,
+
+    const targetPlugin = await withInteractiveTimeout(async () => {
+        return await select({
+            message: 'To which plugin would you like to add the feature?',
+            options: pluginClasses.map(c => ({
+                value: c,
+                label: c.getName() as string,
+            })),
+            maxItems: 10,
+        });
     });
+
     if (isCancel(targetPlugin)) {
         cancel(cancelledMessage);
         process.exit(0);
@@ -57,16 +71,20 @@ export async function selectEntity(plugin: VendurePluginRef): Promise<EntityRef>
     if (entities.length === 0) {
         throw new Error(Messages.NoEntitiesFound);
     }
-    const targetEntity = await select({
-        message: 'Select an entity',
-        options: entities
-            .filter(e => !e.isTranslation())
-            .map(e => ({
-                value: e,
-                label: e.name,
-            })),
-        maxItems: 10,
+
+    const targetEntity = await withInteractiveTimeout(async () => {
+        return await select({
+            message: 'Select an entity',
+            options: entities
+                .filter(e => !e.isTranslation())
+                .map(e => ({
+                    value: e,
+                    label: e.name,
+                })),
+            maxItems: 10,
+        });
     });
+
     if (isCancel(targetEntity)) {
         cancel('Cancelled');
         process.exit(0);
@@ -83,19 +101,23 @@ export async function selectMultiplePluginClasses(
         cancel(Messages.NoPluginsFound);
         process.exit(0);
     }
-    const selectAll = await select({
-        message: 'To which plugin would you like to add the feature?',
-        options: [
-            {
-                value: 'all',
-                label: 'All plugins',
-            },
-            {
-                value: 'specific',
-                label: 'Specific plugins (you will be prompted to select the plugins)',
-            },
-        ],
+
+    const selectAll = await withInteractiveTimeout(async () => {
+        return await select({
+            message: 'To which plugin would you like to add the feature?',
+            options: [
+                {
+                    value: 'all',
+                    label: 'All plugins',
+                },
+                {
+                    value: 'specific',
+                    label: 'Specific plugins (you will be prompted to select the plugins)',
+                },
+            ],
+        });
     });
+
     if (isCancel(selectAll)) {
         cancel(cancelledMessage);
         process.exit(0);
@@ -103,13 +125,17 @@ export async function selectMultiplePluginClasses(
     if (selectAll === 'all') {
         return pluginClasses.map(pc => new VendurePluginRef(pc));
     }
-    const targetPlugins = await multiselect({
-        message: 'Select one or more plugins (use ↑, ↓, space to select)',
-        options: pluginClasses.map(c => ({
-            value: c,
-            label: c.getName() as string,
-        })),
+
+    const targetPlugins = await withInteractiveTimeout(async () => {
+        return await multiselect({
+            message: 'Select one or more plugins (use ↑, ↓, space to select)',
+            options: pluginClasses.map(c => ({
+                value: c,
+                label: c.getName() as string,
+            })),
+        });
     });
+
     if (isCancel(targetPlugins)) {
         cancel(cancelledMessage);
         process.exit(0);
@@ -133,30 +159,33 @@ export async function selectServiceRef(
         throw new Error(Messages.NoServicesFound);
     }
 
-    const result = await select({
-        message: 'Which service contains the business logic for this API extension?',
-        maxItems: 8,
-        options: [
-            ...(canCreateNew
-                ? [
-                      {
-                          value: 'new',
-                          label: `Create new generic service`,
-                      },
-                  ]
-                : []),
-            ...serviceRefs.map(sr => {
-                const features = sr.crudEntityRef
-                    ? `CRUD service for ${sr.crudEntityRef.name}`
-                    : `Generic service`;
-                const label = `${sr.name}: (${features})`;
-                return {
-                    value: sr,
-                    label,
-                };
-            }),
-        ],
+    const result = await withInteractiveTimeout(async () => {
+        return await select({
+            message: 'Which service contains the business logic for this API extension?',
+            maxItems: 8,
+            options: [
+                ...(canCreateNew
+                    ? [
+                          {
+                              value: 'new',
+                              label: `Create new generic service`,
+                          },
+                      ]
+                    : []),
+                ...serviceRefs.map(sr => {
+                    const features = sr.crudEntityRef
+                        ? `CRUD service for ${sr.crudEntityRef.name}`
+                        : `Generic service`;
+                    const label = `${sr.name}: (${features})`;
+                    return {
+                        value: sr,
+                        label,
+                    };
+                }),
+            ],
+        });
     });
+
     if (isCancel(result)) {
         cancel('Cancelled');
         process.exit(0);

+ 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';
     }

+ 12 - 18
packages/cli/src/utilities/ast-utils.ts

@@ -1,4 +1,4 @@
-import { cancel, isCancel, log, select } from '@clack/prompts';
+import { log } from '@clack/prompts';
 import fs from 'fs-extra';
 import path from 'node:path';
 import { Directory, Node, Project, ProjectOptions, ScriptKind, SourceFile } from 'ts-morph';
@@ -6,31 +6,25 @@ import { Directory, Node, Project, ProjectOptions, ScriptKind, SourceFile } from
 import { defaultManipulationSettings } from '../constants';
 import { EntityRef } from '../shared/entity-ref';
 
-export async function selectTsConfigFile() {
+export function selectTsConfigFile() {
     const tsConfigFiles = fs.readdirSync(process.cwd()).filter(f => /^tsconfig.*\.json$/.test(f));
     if (tsConfigFiles.length === 0) {
         throw new Error('No tsconfig files found in current directory');
     }
-    if (tsConfigFiles.length === 1) {
-        return tsConfigFiles[0];
-    }
-    const selectedConfigFile = await select({
-        message: 'Multiple tsconfig files found. Select one:',
-        options: tsConfigFiles.map(c => ({
-            value: c,
-            label: path.basename(c),
-        })),
-        maxItems: 10,
-    });
-    if (isCancel(selectedConfigFile)) {
-        cancel();
-        process.exit(0);
+
+    // Prefer the canonical "tsconfig.json" when multiple configs are present.
+    const defaultConfig = 'tsconfig.json';
+    if (tsConfigFiles.includes(defaultConfig)) {
+        return defaultConfig;
     }
-    return selectedConfigFile as string;
+
+    // Fallback: return the first match (stable order from fs.readdirSync).
+    return tsConfigFiles[0];
 }
 
+// eslint-disable-next-line @typescript-eslint/require-await
 export async function getTsMorphProject(options: ProjectOptions = {}, providedTsConfigPath?: string) {
-    const tsConfigFile = providedTsConfigPath ?? (await selectTsConfigFile());
+    const tsConfigFile = providedTsConfigPath ?? selectTsConfigFile();
     const tsConfigPath = path.join(process.cwd(), tsConfigFile);
     if (!fs.existsSync(tsConfigPath)) {
         throw new Error('No tsconfig.json found in current directory');

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

@@ -1,3 +1,5 @@
+import { log } from '@clack/prompts';
+
 /**
  * Since the AST manipulation is blocking, prompts will not get a
  * chance to be displayed unless we give a small async pause.
@@ -10,3 +12,43 @@ export function isRunningInTsNode(): boolean {
     // @ts-ignore
     return process[Symbol.for('ts-node.register.instance')] != null;
 }
+
+/**
+ * Wraps an interactive prompt with a timeout to prevent hanging in automated environments.
+ * After 60 seconds, it shows a helpful message for AI agents and exits.
+ */
+export async function withInteractiveTimeout<T>(
+    promptFn: () => Promise<T>,
+    timeoutMs: number = 60000,
+): Promise<T> {
+    return new Promise((resolve, reject) => {
+        const timeout = setTimeout(() => {
+            log.warning('\n⚠Interactive mode timeout after 60 seconds\n');
+            log.info('This appears to be an automated environment (AI agent/editor).');
+            log.info('Interactive prompts are not suitable for automated tools.\n');
+            log.info('Please use the non-interactive mode with specific command flags.\n');
+            log.info('Examples:');
+            log.info('   vendure add -p MyPlugin');
+            log.info('   vendure add -e MyEntity');
+            log.info('   vendure add -s MyService');
+            log.info('   vendure migrate -g my-migration');
+            log.info('   vendure migrate -r\n');
+            log.info('--- For complete usage information, run:');
+            log.info('   vendure --help');
+            log.info('   vendure add --help');
+            log.info('   vendure migrate --help\n');
+
+            process.exit(1);
+        }, timeoutMs);
+
+        promptFn()
+            .then(result => {
+                clearTimeout(timeout);
+                resolve(result);
+            })
+            .catch(error => {
+                clearTimeout(timeout);
+                reject(error);
+            });
+    });
+}

+ 2 - 1
packages/cli/tsconfig.json

@@ -8,5 +8,6 @@
     "strictPropertyInitialization": false,
     "sourceMap": true,
     "allowJs": true
-  }
+  },
+  "exclude": ["dist/**/*"]
 }