refactor(core): Extract CLI parsing from command registry (#17676)

This commit is contained in:
Iván Ovejero
2025-07-29 14:23:49 +02:00
committed by GitHub
parent 64902c9559
commit 163565c647
7 changed files with 214 additions and 39 deletions

View File

@@ -29,9 +29,12 @@
"n8n-workflow": "workspace:^",
"picocolors": "catalog:",
"reflect-metadata": "catalog:",
"winston": "3.14.2"
"winston": "3.14.2",
"yargs-parser": "21.1.1"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*"
"@n8n/typescript-config": "workspace:*",
"@types/yargs-parser": "21.0.0",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1,117 @@
import { mock } from 'jest-mock-extended';
import z from 'zod';
import { CliParser } from '../cli-parser';
describe('parse', () => {
it('should parse `argv` without flags schema', () => {
const cliParser = new CliParser(mock());
const result = cliParser.parse({ argv: ['node', 'script.js', 'arg1', 'arg2'] });
expect(result).toEqual({ flags: {}, args: ['arg1', 'arg2'] });
});
it('should parse `argv` with flags schema', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
verbose: z.boolean().optional(),
name: z.string().optional(),
});
const result = cliParser.parse({
argv: ['node', 'script.js', '--verbose', '--name', 'test', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: { verbose: true, name: 'test' },
args: ['arg1'],
});
});
it('should ignore flags not defined in schema', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
name: z.string().optional(),
// ignored is absent
});
const result = cliParser.parse({
argv: ['node', 'script.js', '--name', 'test', '--ignored', 'value', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: {
name: 'test',
// ignored is absent
},
args: ['arg1'],
});
});
it('should handle a numeric value for `--id` flag', () => {
const cliParser = new CliParser(mock());
const result = cliParser.parse({
argv: ['node', 'script.js', '--id', '123', 'arg1'],
flagsSchema: z.object({
id: z.string(),
}),
});
expect(result).toEqual({
flags: { id: '123' },
args: ['arg1'],
});
});
it('should handle positional arguments', () => {
const cliParser = new CliParser(mock());
const result = cliParser.parse({
argv: ['node', 'script.js', '123', 'true'],
});
expect(result.args).toEqual(['123', 'true']);
expect(typeof result.args[0]).toBe('string');
expect(typeof result.args[1]).toBe('string');
});
it('should handle required flags with aliases', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
name: z.string(),
});
// @ts-expect-error zod was monkey-patched to support aliases
flagsSchema.shape.name._def._alias = 'n';
const result = cliParser.parse({
argv: ['node', 'script.js', '-n', 'test', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: { name: 'test' },
args: ['arg1'],
});
});
it('should handle optional flags with aliases', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
name: z.optional(z.string()),
});
// @ts-expect-error zod was monkey-patched to support aliases
flagsSchema.shape.name._def.innerType._def._alias = 'n';
const result = cliParser.parse({
argv: ['node', 'script.js', '-n', 'test', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: { name: 'test' },
args: ['arg1'],
});
});
});

View File

@@ -0,0 +1,63 @@
import { Service } from '@n8n/di';
import argvParser from 'yargs-parser';
import type { z } from 'zod';
import { Logger } from './logging';
type CliInput<Flags extends z.ZodRawShape> = {
argv: string[];
flagsSchema?: z.ZodObject<Flags>;
description?: string;
examples?: string[];
};
type ParsedArgs<Flags = Record<string, unknown>> = {
flags: Flags;
args: string[];
};
@Service()
export class CliParser {
constructor(private readonly logger: Logger) {}
parse<Flags extends z.ZodRawShape>(
input: CliInput<Flags>,
): ParsedArgs<z.infer<z.ZodObject<Flags>>> {
// eslint-disable-next-line id-denylist
const { _: rest, ...rawFlags } = argvParser(input.argv, { string: ['id'] });
let flags = {} as z.infer<z.ZodObject<Flags>>;
if (input.flagsSchema) {
for (const key in input.flagsSchema.shape) {
const flagSchema = input.flagsSchema.shape[key];
let schemaDef = flagSchema._def as z.ZodTypeDef & {
typeName: string;
innerType?: z.ZodType;
_alias?: string;
};
if (schemaDef.typeName === 'ZodOptional' && schemaDef.innerType) {
schemaDef = schemaDef.innerType._def as typeof schemaDef;
}
const alias = schemaDef._alias;
if (alias?.length && !(key in rawFlags) && rawFlags[alias]) {
rawFlags[key] = rawFlags[alias] as unknown;
}
}
flags = input.flagsSchema.parse(rawFlags);
}
const args = rest.map(String).slice(2);
this.logger.debug('Received CLI command', {
execPath: rest[0],
scriptPath: rest[1],
args,
flags,
});
return { flags, args };
}
}

View File

@@ -7,3 +7,4 @@ export { Logger } from './logging/logger';
export { ModuleRegistry } from './modules/module-registry';
export { ModulesConfig, ModuleName } from './modules/modules.config';
export { isContainedWithin, safeJoinPath } from './utils/path-util';
export { CliParser } from './cli-parser';

View File

@@ -1,4 +1,5 @@
import type { Logger, ModuleRegistry } from '@n8n/backend-common';
import { CliParser } from '@n8n/backend-common';
import { CommandMetadata } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
@@ -17,6 +18,7 @@ describe('CommandRegistry', () => {
const logger = mock<Logger>();
let originalProcessArgv: string[];
let mockProcessExit: jest.SpyInstance;
const cliParser = new CliParser(logger);
class TestCommand {
flags: any;
@@ -64,7 +66,7 @@ describe('CommandRegistry', () => {
it('should execute the specified command', async () => {
process.argv = ['node', 'n8n', 'test-command'];
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
await commandRegistry.execute();
expect(moduleRegistry.loadModules).toHaveBeenCalled();
@@ -83,7 +85,7 @@ describe('CommandRegistry', () => {
const commandInstance = Container.get(commandClass);
commandInstance.run = jest.fn().mockRejectedValue(error);
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
await commandRegistry.execute();
expect(commandInstance.catch).toHaveBeenCalledWith(error);
@@ -93,7 +95,7 @@ describe('CommandRegistry', () => {
it('should parse and apply command flags', async () => {
process.argv = ['node', 'n8n', 'test-command', '--flag1', 'value1', '--flag2', '-s', '123'];
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
await commandRegistry.execute();
const commandInstance = Container.get(commandMetadata.get('test-command')!.class);
@@ -107,7 +109,7 @@ describe('CommandRegistry', () => {
it('should handle alias flags', async () => {
process.argv = ['node', 'n8n', 'test-command', '--flag1', 'value1', '-s', '123'];
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
await commandRegistry.execute();
const commandInstance = Container.get(commandMetadata.get('test-command')!.class);
@@ -120,7 +122,7 @@ describe('CommandRegistry', () => {
it('should exit with error when command not found', async () => {
process.argv = ['node', 'n8n', 'non-existent-command'];
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
await commandRegistry.execute();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
@@ -130,7 +132,7 @@ describe('CommandRegistry', () => {
it('should display help when --help flag is used', async () => {
process.argv = ['node', 'n8n', 'test-command', '--help'];
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
await commandRegistry.execute();
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('USAGE'));
@@ -143,7 +145,7 @@ describe('CommandRegistry', () => {
it('should list all commands when global help is requested', async () => {
process.argv = ['node', 'n8n', '--help'];
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
await commandRegistry.execute();
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Available commands'));
@@ -153,7 +155,7 @@ describe('CommandRegistry', () => {
it('should display proper command usage with printCommandUsage', () => {
process.argv = ['node', 'n8n', 'test-command'];
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
const commandEntry = commandMetadata.get('test-command')!;
commandRegistry.printCommandUsage(commandEntry);

View File

@@ -1,9 +1,8 @@
import { Logger, ModuleRegistry } from '@n8n/backend-common';
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 argvParser from 'yargs-parser';
import { z } from 'zod';
import './zod-alias-support';
@@ -15,16 +14,12 @@ import './zod-alias-support';
export class CommandRegistry {
private commandName: string;
private readonly argv: argvParser.Arguments;
constructor(
private readonly commandMetadata: CommandMetadata,
private readonly moduleRegistry: ModuleRegistry,
private readonly logger: Logger,
private readonly cliParser: CliParser,
) {
// yargs-parser was resolving number like strings to numbers, which is not what we want
// eslint-disable-next-line id-denylist
this.argv = argvParser(process.argv.slice(2), { string: ['id'] });
this.commandName = process.argv[2] ?? 'start';
}
@@ -53,13 +48,18 @@ export class CommandRegistry {
return process.exit(1);
}
if (this.argv.help || this.argv.h) {
if (process.argv.includes('--help') || process.argv.includes('-h')) {
this.printCommandUsage(commandEntry);
return process.exit(0);
}
const { flags } = this.cliParser.parse({
argv: process.argv,
flagsSchema: commandEntry.flagsSchema,
});
const command = Container.get(commandEntry.class);
command.flags = this.parseFlags(commandEntry);
command.flags = flags;
let error: Error | undefined = undefined;
try {
@@ -73,26 +73,6 @@ export class CommandRegistry {
}
}
parseFlags(commandEntry: CommandEntry) {
if (!commandEntry.flagsSchema) return {};
const { _, ...argv } = this.argv;
Object.entries(commandEntry.flagsSchema.shape).forEach(([key, flagSchema]) => {
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 alias = schemaDef._alias;
if (alias?.length && !(key in argv) && argv[alias]) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
argv[key] = argv[alias];
}
});
return commandEntry.flagsSchema.parse(argv);
}
async listAllCommands() {
// Import all command files to register all the non-module commands
const commandFiles = await glob('./commands/**/*.js', {

9
pnpm-lock.yaml generated
View File

@@ -501,10 +501,19 @@ importers:
winston:
specifier: 3.14.2
version: 3.14.2
yargs-parser:
specifier: 21.1.1
version: 21.1.1
devDependencies:
'@n8n/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/yargs-parser':
specifier: 21.0.0
version: 21.0.0
zod:
specifier: 'catalog:'
version: 3.25.67
packages/@n8n/backend-test-utils:
dependencies: