import { CliParser, Logger, ModuleRegistry } from '@n8n/backend-common'; import { CommandMetadata, type CommandEntry } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; import glob from 'fast-glob'; import { access } from 'node:fs/promises'; import path from 'node:path'; import picocolors from 'picocolors'; import { z, ZodError } from 'zod'; import './zod-alias-support'; /** * Registry that manages CLI commands, their execution, and metadata. * Handles command discovery, flag parsing, and execution lifecycle. */ @Service() export class CommandRegistry { private commandName: string; constructor( private readonly commandMetadata: CommandMetadata, private readonly moduleRegistry: ModuleRegistry, private readonly logger: Logger, private readonly cliParser: CliParser, ) { this.commandName = process.argv[2] ?? 'start'; } async execute() { if (this.commandName === '--help' || this.commandName === '-h') { await this.listAllCommands(); return process.exit(0); } if (this.commandName === 'executeBatch') { this.logger.warn('WARNING: "executeBatch" has been renamed to "execute-batch".'); this.commandName = 'execute-batch'; } // Load the command file if it exists. A missing file means this isn't a // regular command (it may still be provided by a module below), so we let // the "not found" handling take over. But if the file exists and fails to // load, surface the real error instead of masking a broken dependency as // a missing command. const relativeCommandPath = `./commands/${this.commandName.replaceAll(':', '/')}.js`; const commandFileExists = await access(path.resolve(__dirname, relativeCommandPath)).then( () => true, () => false, ); if (commandFileExists) { try { await import(relativeCommandPath); } catch (error) { this.logger.error(picocolors.red(`Error: Failed to load command "${this.commandName}"`)); throw error; } } // Load modules to ensure all module commands are registered await this.moduleRegistry.loadModules(); const commandEntry = this.commandMetadata.get(this.commandName); if (!commandEntry) { this.logger.error(picocolors.red(`Error: Command "${this.commandName}" not found`)); return process.exit(1); } if (process.argv.includes('--help') || process.argv.includes('-h')) { this.printCommandUsage(commandEntry); return process.exit(0); } let flags: Record; try { ({ flags } = this.cliParser.parse({ argv: process.argv, flagsSchema: commandEntry.flagsSchema, })); } catch (error) { if (error instanceof ZodError) { this.logger.error(this.formatZodError(error)); this.logger.info(''); this.printCommandUsage(commandEntry); return process.exit(1); } // Preserve previous behavior for non-Zod errors throw error; } const command = Container.get(commandEntry.class); command.flags = flags; let error: Error | undefined = undefined; try { await command.init?.(); await command.run(); } catch (e) { error = e as Error; await command.catch?.(error); } finally { await command.finally?.(error); } } async listAllCommands() { // Import all command files to register all the non-module commands const commandFiles = await glob('./commands/**/*.js', { ignore: ['**/__tests__/**'], cwd: __dirname, }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return await Promise.all(commandFiles.map(async (filePath) => await import(filePath))); // Load/List module commands after legacy commands await this.moduleRegistry.loadModules(); this.logger.info('Available commands:'); for (const [name, { description }] of this.commandMetadata.getEntries()) { this.logger.info( ` ${picocolors.bold(picocolors.green(name))}: \n ${description.split('\n')[0]}`, ); } this.logger.info( '\nFor more detailed information, visit:\nhttps://docs.n8n.io/hosting/cli-commands/', ); } printCommandUsage(commandEntry: CommandEntry) { const { commandName } = this; let output = ''; output += `${picocolors.bold('USAGE')}\n`; output += ` $ n8n ${commandName}\n\n`; const { flagsSchema } = commandEntry; if (flagsSchema && Object.keys(flagsSchema.shape).length > 0) { const flagLines: Array<[string, string]> = []; const flagEntries = Object.entries( z .object({ help: z.boolean().alias('h').describe('Show CLI help'), }) .merge(flagsSchema).shape, ); for (const [flagName, flagSchema] of flagEntries) { let schemaDef = flagSchema._def as z.ZodTypeDef & { typeName: string; innerType?: z.ZodType; }; if (schemaDef.typeName === 'ZodOptional' && schemaDef.innerType) { schemaDef = schemaDef.innerType._def as typeof schemaDef; } const typeName = schemaDef.typeName; let flagString = `--${flagName}`; if (schemaDef._alias) { flagString = `-${schemaDef._alias}, ${flagString}`; } if (['ZodString', 'ZodNumber', 'ZodArray'].includes(typeName)) { flagString += ' '; } let flagLine = flagSchema.description ?? ''; if ('defaultValue' in schemaDef) { const defaultValue = (schemaDef as z.ZodDefaultDef).defaultValue() as unknown; flagLine += ` [default: ${String(defaultValue)}]`; } flagLines.push([flagString, flagLine]); } const flagColumnWidth = Math.max(...flagLines.map(([flagString]) => flagString.length)); output += `${picocolors.bold('FLAGS')}\n`; output += flagLines .map(([flagString, flagLine]) => ` ${flagString.padEnd(flagColumnWidth)} ${flagLine}`) .join('\n'); output += '\n\n'; } output += `${picocolors.bold('DESCRIPTION')}\n`; output += ` ${commandEntry.description}\n`; if (commandEntry.examples?.length) { output += `\n${picocolors.bold('EXAMPLES')}\n`; output += commandEntry.examples .map((example) => ` $ n8n ${commandName}${example ? ` ${example}` : ''}`) .join('\n'); output += '\n'; } this.logger.info(output); } private formatZodError(error: ZodError): string { const issuesByFlag: Record = {}; for (const issue of error.issues) { const flag = (issue.path[0] as string | undefined) ?? 'flags'; if (!issuesByFlag[flag]) issuesByFlag[flag] = []; issuesByFlag[flag].push(issue); } let output = ''; output += picocolors.red( `\nError: Invalid flags provided for command "${this.commandName}".\n\n`, ); for (const [flag, issues] of Object.entries(issuesByFlag)) { const flagLabel = flag === 'flags' ? '(general)' : `--${flag}`; output += ` ${picocolors.bold(flagLabel)}\n`; for (const issue of issues) { output += ` - ${issue.message}\n`; } output += '\n'; } return output.trimEnd(); } }