mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
refactor(core): Extract CLI parsing from command registry (#17676)
This commit is contained in:
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
117
packages/@n8n/backend-common/src/__tests__/cli-parser.test.ts
Normal file
117
packages/@n8n/backend-common/src/__tests__/cli-parser.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
63
packages/@n8n/backend-common/src/cli-parser.ts
Normal file
63
packages/@n8n/backend-common/src/cli-parser.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
9
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user