n8n/packages/cli/src/command-registry.ts
Tomi Turtiainen a4757cf009
chore: Initial V2 changes (#22553)
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
Co-authored-by: Daria <daria.staferova@n8n.io>
Co-authored-by: Svetoslav Dekov <svetoslav.dekov@n8n.io>
Co-authored-by: Nikhil Kuriakose <nikhilkuria@gmail.com>
Co-authored-by: Charlie Kolb <charlie@n8n.io>
2025-12-01 20:44:59 +02:00

210 lines
6.0 KiB
TypeScript

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 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';
}
// Try to load regular commands
try {
await import(`./commands/${this.commandName.replaceAll(':', '/')}.js`);
} catch {
// Do nothing
}
// 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<string, unknown>;
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 += ' <value>';
}
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<string, z.ZodIssue[]> = {};
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();
}
}