diff --git a/packages/@n8n/backend-common/package.json b/packages/@n8n/backend-common/package.json index a4b5026d42..128a84eff8 100644 --- a/packages/@n8n/backend-common/package.json +++ b/packages/@n8n/backend-common/package.json @@ -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:" } } diff --git a/packages/@n8n/backend-common/src/__tests__/cli-parser.test.ts b/packages/@n8n/backend-common/src/__tests__/cli-parser.test.ts new file mode 100644 index 0000000000..8e42f2ebdd --- /dev/null +++ b/packages/@n8n/backend-common/src/__tests__/cli-parser.test.ts @@ -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'], + }); + }); +}); diff --git a/packages/@n8n/backend-common/src/cli-parser.ts b/packages/@n8n/backend-common/src/cli-parser.ts new file mode 100644 index 0000000000..1ac603df50 --- /dev/null +++ b/packages/@n8n/backend-common/src/cli-parser.ts @@ -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 = { + argv: string[]; + flagsSchema?: z.ZodObject; + description?: string; + examples?: string[]; +}; + +type ParsedArgs> = { + flags: Flags; + args: string[]; +}; + +@Service() +export class CliParser { + constructor(private readonly logger: Logger) {} + + parse( + input: CliInput, + ): ParsedArgs>> { + // eslint-disable-next-line id-denylist + const { _: rest, ...rawFlags } = argvParser(input.argv, { string: ['id'] }); + + let flags = {} as z.infer>; + 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 }; + } +} diff --git a/packages/@n8n/backend-common/src/index.ts b/packages/@n8n/backend-common/src/index.ts index 8f26f40690..f80a7e740f 100644 --- a/packages/@n8n/backend-common/src/index.ts +++ b/packages/@n8n/backend-common/src/index.ts @@ -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'; diff --git a/packages/cli/src/__tests__/command-registry.test.ts b/packages/cli/src/__tests__/command-registry.test.ts index 5a130f0555..c9333f3fc1 100644 --- a/packages/cli/src/__tests__/command-registry.test.ts +++ b/packages/cli/src/__tests__/command-registry.test.ts @@ -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(); 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); diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts index 0fa2ac07f9..96376ef1cb 100644 --- a/packages/cli/src/command-registry.ts +++ b/packages/cli/src/command-registry.ts @@ -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', { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b9b2c569a..26dd9e1ee2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: