소스 검색

feat(cli): Add non-interactive mode and create migration-operations

HouseinIsProgramming 7 달 전
부모
커밋
84a2852c88

+ 32 - 8
packages/cli/src/commands/command-declarations.ts

@@ -1,3 +1,5 @@
+import { log } from '@clack/prompts';
+
 import { CliCommandDefinition } from '../shared/cli-command-definition';
 
 export const cliCommands: CliCommandDefinition[] = [
@@ -13,9 +15,31 @@ export const cliCommands: CliCommandDefinition[] = [
     {
         name: 'migrate',
         description: 'Generate, run or revert a database migration',
-        action: async () => {
+        options: [
+            {
+                flag: '-g, --generate <name>',
+                description: 'Generate a new migration with the specified name',
+                required: false,
+            },
+            {
+                flag: '-r, --run',
+                description: 'Run pending migrations',
+                required: false,
+            },
+            {
+                flag: '--revert',
+                description: 'Revert the last migration',
+                required: false,
+            },
+            {
+                flag: '-o, --output-dir <path>',
+                description: 'Output directory for generated migrations',
+                required: false,
+            },
+        ],
+        action: async options => {
             const { migrateCommand } = await import('./migrate/migrate');
-            await migrateCommand();
+            await migrateCommand(options);
             process.exit(0);
         },
     },
@@ -36,19 +60,19 @@ export const cliCommands: CliCommandDefinition[] = [
                 defaultValue: false,
             },
         ],
-        action: async (options) => {
+        action: options => {
             // Example action implementation with options
-            console.log('Example command executed');
+            log.info('Example command executed');
             if (options) {
                 // Validate required options
                 if (!options.file) {
-                    console.error('Error: --file option is required');
+                    log.error('Error: --file option is required');
                     process.exit(1);
                 }
-                console.log('File path:', options.file);
-                console.log('Verbose mode:', options.verbose);
+                log.info(`File path: ${String(options.file)}`);
+                log.info(`Verbose mode: ${String(options.verbose)}`);
             }
             process.exit(0);
         },
     },
-]; 
+];

+ 74 - 4
packages/cli/src/commands/migrate/migrate.ts

@@ -1,18 +1,85 @@
 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 {
+    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'));
@@ -31,12 +98,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!');

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

@@ -0,0 +1,199 @@
+import { log } from '@clack/prompts';
+import { generateMigration, revertLastMigration, runMigrations, VendureConfig } from '@vendure/core';
+import * as fs from 'fs-extra';
+import path from 'path';
+
+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 {
+        // Check if we're in a proper Vendure project directory
+        if (!isVendureProjectDirectory()) {
+            return {
+                success: false,
+                message:
+                    'Error: Not in a Vendure project directory. Please run this command from your Vendure project root.',
+            };
+        }
+
+        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',
+            };
+        }
+
+        // Validate name
+        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 {
+        // Check if we're in a proper Vendure project directory
+        if (!isVendureProjectDirectory()) {
+            return {
+                success: false,
+                message:
+                    'Error: Not in a Vendure project directory. Please run this command from your Vendure project root.',
+            };
+        }
+
+        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 {
+        // Check if we're in a proper Vendure project directory
+        if (!isVendureProjectDirectory()) {
+            return {
+                success: false,
+                message:
+                    'Error: Not in a Vendure project directory. Please run this command from your Vendure project root.',
+            };
+        }
+
+        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 isVendureProjectDirectory(): boolean {
+    const cwd = process.cwd();
+
+    // Check for common Vendure project files
+    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'));
+
+    // Check if package.json contains Vendure dependencies
+    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;
+}
+
+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));
+}

+ 1 - 1
packages/cli/src/shared/cli-command-definition.ts

@@ -14,4 +14,4 @@ export interface CliCommandDefinition {
 
 export interface CliCommandConfig {
     commands: CliCommandDefinition[];
-} 
+}

+ 7 - 6
packages/cli/src/shared/command-registry.ts

@@ -1,25 +1,26 @@
 import { Command } from 'commander';
+
 import { CliCommandDefinition } from './cli-command-definition';
 
 export function registerCommands(program: Command, commands: CliCommandDefinition[]): void {
-    commands.forEach((commandDef) => {
+    commands.forEach(commandDef => {
         const command = program
             .command(commandDef.name)
             .description(commandDef.description)
-            .action(async (options) => {
+            .action(async options => {
                 await commandDef.action(options);
             });
 
         // Add options if they exist
         if (commandDef.options) {
-            commandDef.options.forEach((option) => {
+            commandDef.options.forEach(option => {
                 // Handle both required and optional options
-                const optionString = option.required 
+                const optionString = option.required
                     ? option.flag
                     : option.flag.replace(/<([^>]+)>/g, '[$1]');
-                
+
                 command.option(optionString, option.description, option.defaultValue);
             });
         }
     });
-} 
+}