mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
refactor(core): Extract CLI parsing from command registry (#17676)
This commit is contained in:
@@ -29,9 +29,12 @@
|
|||||||
"n8n-workflow": "workspace:^",
|
"n8n-workflow": "workspace:^",
|
||||||
"picocolors": "catalog:",
|
"picocolors": "catalog:",
|
||||||
"reflect-metadata": "catalog:",
|
"reflect-metadata": "catalog:",
|
||||||
"winston": "3.14.2"
|
"winston": "3.14.2",
|
||||||
|
"yargs-parser": "21.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 { ModuleRegistry } from './modules/module-registry';
|
||||||
export { ModulesConfig, ModuleName } from './modules/modules.config';
|
export { ModulesConfig, ModuleName } from './modules/modules.config';
|
||||||
export { isContainedWithin, safeJoinPath } from './utils/path-util';
|
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 type { Logger, ModuleRegistry } from '@n8n/backend-common';
|
||||||
|
import { CliParser } from '@n8n/backend-common';
|
||||||
import { CommandMetadata } from '@n8n/decorators';
|
import { CommandMetadata } from '@n8n/decorators';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
@@ -17,6 +18,7 @@ describe('CommandRegistry', () => {
|
|||||||
const logger = mock<Logger>();
|
const logger = mock<Logger>();
|
||||||
let originalProcessArgv: string[];
|
let originalProcessArgv: string[];
|
||||||
let mockProcessExit: jest.SpyInstance;
|
let mockProcessExit: jest.SpyInstance;
|
||||||
|
const cliParser = new CliParser(logger);
|
||||||
|
|
||||||
class TestCommand {
|
class TestCommand {
|
||||||
flags: any;
|
flags: any;
|
||||||
@@ -64,7 +66,7 @@ describe('CommandRegistry', () => {
|
|||||||
it('should execute the specified command', async () => {
|
it('should execute the specified command', async () => {
|
||||||
process.argv = ['node', 'n8n', 'test-command'];
|
process.argv = ['node', 'n8n', 'test-command'];
|
||||||
|
|
||||||
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
|
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
|
||||||
await commandRegistry.execute();
|
await commandRegistry.execute();
|
||||||
|
|
||||||
expect(moduleRegistry.loadModules).toHaveBeenCalled();
|
expect(moduleRegistry.loadModules).toHaveBeenCalled();
|
||||||
@@ -83,7 +85,7 @@ describe('CommandRegistry', () => {
|
|||||||
const commandInstance = Container.get(commandClass);
|
const commandInstance = Container.get(commandClass);
|
||||||
commandInstance.run = jest.fn().mockRejectedValue(error);
|
commandInstance.run = jest.fn().mockRejectedValue(error);
|
||||||
|
|
||||||
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
|
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
|
||||||
await commandRegistry.execute();
|
await commandRegistry.execute();
|
||||||
|
|
||||||
expect(commandInstance.catch).toHaveBeenCalledWith(error);
|
expect(commandInstance.catch).toHaveBeenCalledWith(error);
|
||||||
@@ -93,7 +95,7 @@ describe('CommandRegistry', () => {
|
|||||||
it('should parse and apply command flags', async () => {
|
it('should parse and apply command flags', async () => {
|
||||||
process.argv = ['node', 'n8n', 'test-command', '--flag1', 'value1', '--flag2', '-s', '123'];
|
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();
|
await commandRegistry.execute();
|
||||||
|
|
||||||
const commandInstance = Container.get(commandMetadata.get('test-command')!.class);
|
const commandInstance = Container.get(commandMetadata.get('test-command')!.class);
|
||||||
@@ -107,7 +109,7 @@ describe('CommandRegistry', () => {
|
|||||||
it('should handle alias flags', async () => {
|
it('should handle alias flags', async () => {
|
||||||
process.argv = ['node', 'n8n', 'test-command', '--flag1', 'value1', '-s', '123'];
|
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();
|
await commandRegistry.execute();
|
||||||
|
|
||||||
const commandInstance = Container.get(commandMetadata.get('test-command')!.class);
|
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 () => {
|
it('should exit with error when command not found', async () => {
|
||||||
process.argv = ['node', 'n8n', 'non-existent-command'];
|
process.argv = ['node', 'n8n', 'non-existent-command'];
|
||||||
|
|
||||||
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
|
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
|
||||||
await commandRegistry.execute();
|
await commandRegistry.execute();
|
||||||
|
|
||||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
|
||||||
@@ -130,7 +132,7 @@ describe('CommandRegistry', () => {
|
|||||||
it('should display help when --help flag is used', async () => {
|
it('should display help when --help flag is used', async () => {
|
||||||
process.argv = ['node', 'n8n', 'test-command', '--help'];
|
process.argv = ['node', 'n8n', 'test-command', '--help'];
|
||||||
|
|
||||||
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
|
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
|
||||||
await commandRegistry.execute();
|
await commandRegistry.execute();
|
||||||
|
|
||||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('USAGE'));
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('USAGE'));
|
||||||
@@ -143,7 +145,7 @@ describe('CommandRegistry', () => {
|
|||||||
it('should list all commands when global help is requested', async () => {
|
it('should list all commands when global help is requested', async () => {
|
||||||
process.argv = ['node', 'n8n', '--help'];
|
process.argv = ['node', 'n8n', '--help'];
|
||||||
|
|
||||||
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger);
|
commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger, cliParser);
|
||||||
await commandRegistry.execute();
|
await commandRegistry.execute();
|
||||||
|
|
||||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Available commands'));
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Available commands'));
|
||||||
@@ -153,7 +155,7 @@ describe('CommandRegistry', () => {
|
|||||||
it('should display proper command usage with printCommandUsage', () => {
|
it('should display proper command usage with printCommandUsage', () => {
|
||||||
process.argv = ['node', 'n8n', 'test-command'];
|
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')!;
|
const commandEntry = commandMetadata.get('test-command')!;
|
||||||
commandRegistry.printCommandUsage(commandEntry);
|
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 { CommandMetadata, type CommandEntry } from '@n8n/decorators';
|
||||||
import { Container, Service } from '@n8n/di';
|
import { Container, Service } from '@n8n/di';
|
||||||
import glob from 'fast-glob';
|
import glob from 'fast-glob';
|
||||||
import picocolors from 'picocolors';
|
import picocolors from 'picocolors';
|
||||||
import argvParser from 'yargs-parser';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import './zod-alias-support';
|
import './zod-alias-support';
|
||||||
|
|
||||||
@@ -15,16 +14,12 @@ import './zod-alias-support';
|
|||||||
export class CommandRegistry {
|
export class CommandRegistry {
|
||||||
private commandName: string;
|
private commandName: string;
|
||||||
|
|
||||||
private readonly argv: argvParser.Arguments;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly commandMetadata: CommandMetadata,
|
private readonly commandMetadata: CommandMetadata,
|
||||||
private readonly moduleRegistry: ModuleRegistry,
|
private readonly moduleRegistry: ModuleRegistry,
|
||||||
private readonly logger: Logger,
|
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';
|
this.commandName = process.argv[2] ?? 'start';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +48,18 @@ export class CommandRegistry {
|
|||||||
return process.exit(1);
|
return process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.argv.help || this.argv.h) {
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
||||||
this.printCommandUsage(commandEntry);
|
this.printCommandUsage(commandEntry);
|
||||||
return process.exit(0);
|
return process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { flags } = this.cliParser.parse({
|
||||||
|
argv: process.argv,
|
||||||
|
flagsSchema: commandEntry.flagsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
const command = Container.get(commandEntry.class);
|
const command = Container.get(commandEntry.class);
|
||||||
command.flags = this.parseFlags(commandEntry);
|
command.flags = flags;
|
||||||
|
|
||||||
let error: Error | undefined = undefined;
|
let error: Error | undefined = undefined;
|
||||||
try {
|
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() {
|
async listAllCommands() {
|
||||||
// Import all command files to register all the non-module commands
|
// Import all command files to register all the non-module commands
|
||||||
const commandFiles = await glob('./commands/**/*.js', {
|
const commandFiles = await glob('./commands/**/*.js', {
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -501,10 +501,19 @@ importers:
|
|||||||
winston:
|
winston:
|
||||||
specifier: 3.14.2
|
specifier: 3.14.2
|
||||||
version: 3.14.2
|
version: 3.14.2
|
||||||
|
yargs-parser:
|
||||||
|
specifier: 21.1.1
|
||||||
|
version: 21.1.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@n8n/typescript-config':
|
'@n8n/typescript-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../typescript-config
|
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:
|
packages/@n8n/backend-test-utils:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user