diff --git a/packages/@n8n/decorators/src/command/__tests__/command.test.ts b/packages/@n8n/decorators/src/command/__tests__/command.test.ts new file mode 100644 index 0000000000..afe59e7e5e --- /dev/null +++ b/packages/@n8n/decorators/src/command/__tests__/command.test.ts @@ -0,0 +1,116 @@ +import { Container } from '@n8n/di'; +import { z } from 'zod'; + +import { Command } from '../command'; +import { CommandMetadata } from '../command-metadata'; + +describe('@Command decorator', () => { + let commandMetadata: CommandMetadata; + + beforeEach(() => { + jest.resetAllMocks(); + Container.reset(); + + commandMetadata = new CommandMetadata(); + Container.set(CommandMetadata, commandMetadata); + }); + + it('should register command in CommandMetadata', () => { + @Command({ + name: 'test', + description: 'Test command', + examples: ['example usage'], + flagsSchema: z.object({}), + }) + class TestCommand { + async run() {} + } + + const registeredCommands = commandMetadata.getEntries(); + + expect(registeredCommands).toHaveLength(1); + const [commandName, entry] = registeredCommands[0]; + expect(commandName).toBe('test'); + expect(entry.class).toBe(TestCommand); + }); + + it('should register multiple commands', () => { + @Command({ + name: 'first-command', + description: 'First test command', + examples: ['example 1'], + flagsSchema: z.object({}), + }) + class FirstCommand { + async run() {} + } + + @Command({ + name: 'second-command', + description: 'Second test command', + examples: ['example 2'], + flagsSchema: z.object({}), + }) + class SecondCommand { + async run() {} + } + + @Command({ + name: 'third-command', + description: 'Third test command', + examples: ['example 3'], + flagsSchema: z.object({}), + }) + class ThirdCommand { + async run() {} + } + + const registeredCommands = commandMetadata.getEntries(); + + expect(registeredCommands).toHaveLength(3); + expect(commandMetadata.get('first-command')?.class).toBe(FirstCommand); + expect(commandMetadata.get('second-command')?.class).toBe(SecondCommand); + expect(commandMetadata.get('third-command')?.class).toBe(ThirdCommand); + }); + + it('should apply Service decorator', () => { + @Command({ + name: 'test', + description: 'Test command', + examples: ['example usage'], + flagsSchema: z.object({}), + }) + class TestCommand { + async run() {} + } + + expect(Container.has(TestCommand)).toBe(true); + }); + + it('stores the command metadata correctly', () => { + const name = 'test-cmd'; + const description = 'Test command description'; + const examples = ['example 1', 'example 2']; + const flagsSchema = z.object({ + flag1: z.string(), + flag2: z.boolean().optional(), + }); + + @Command({ + name, + description, + examples, + flagsSchema, + }) + class TestCommand { + async run() {} + } + + const entry = commandMetadata.get(name); + expect(entry).toBeDefined(); + expect(entry?.description).toBe(description); + expect(entry?.examples).toEqual(examples); + expect(entry?.flagsSchema).toBe(flagsSchema); + expect(entry?.class).toBe(TestCommand); + }); +}); diff --git a/packages/@n8n/decorators/src/command/command-metadata.ts b/packages/@n8n/decorators/src/command/command-metadata.ts new file mode 100644 index 0000000000..af955f9ba4 --- /dev/null +++ b/packages/@n8n/decorators/src/command/command-metadata.ts @@ -0,0 +1,20 @@ +import { Service } from '@n8n/di'; + +import type { CommandEntry } from './types'; + +@Service() +export class CommandMetadata { + private readonly commands: Map = new Map(); + + register(name: string, entry: CommandEntry) { + this.commands.set(name, entry); + } + + get(name: string) { + return this.commands.get(name); + } + + getEntries() { + return [...this.commands.entries()]; + } +} diff --git a/packages/@n8n/decorators/src/command/command.ts b/packages/@n8n/decorators/src/command/command.ts new file mode 100644 index 0000000000..31e46316db --- /dev/null +++ b/packages/@n8n/decorators/src/command/command.ts @@ -0,0 +1,18 @@ +import { Container, Service } from '@n8n/di'; + +import { CommandMetadata } from './command-metadata'; +import type { CommandClass, CommandOptions } from './types'; + +export const Command = + ({ name, description, examples, flagsSchema }: CommandOptions): ClassDecorator => + (target) => { + const commandClass = target as unknown as CommandClass; + Container.get(CommandMetadata).register(name, { + description, + flagsSchema, + class: commandClass, + examples, + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Service()(target); + }; diff --git a/packages/@n8n/decorators/src/command/index.ts b/packages/@n8n/decorators/src/command/index.ts new file mode 100644 index 0000000000..c58467b7e4 --- /dev/null +++ b/packages/@n8n/decorators/src/command/index.ts @@ -0,0 +1,3 @@ +export { Command } from './command'; +export { CommandMetadata } from './command-metadata'; +export type { ICommand, CommandClass, CommandEntry } from './types'; diff --git a/packages/@n8n/decorators/src/command/types.ts b/packages/@n8n/decorators/src/command/types.ts new file mode 100644 index 0000000000..7e6785e159 --- /dev/null +++ b/packages/@n8n/decorators/src/command/types.ts @@ -0,0 +1,28 @@ +import type { Constructable } from '@n8n/di'; +import type { ZodObject, ZodTypeAny } from 'zod'; + +type FlagsSchema = ZodObject>; + +export type CommandOptions = { + name: string; + description: string; + examples?: string[]; + flagsSchema?: FlagsSchema; +}; + +export type ICommand = { + flags?: object; + init?: () => Promise; + run: () => Promise; + catch?: (e: Error) => Promise; + finally?: (e?: Error) => Promise; +}; + +export type CommandClass = Constructable; + +export type CommandEntry = { + class: CommandClass; + description: string; + examples?: string[]; + flagsSchema?: FlagsSchema; +}; diff --git a/packages/@n8n/decorators/src/index.ts b/packages/@n8n/decorators/src/index.ts index 7c21df416a..03b8c3413f 100644 --- a/packages/@n8n/decorators/src/index.ts +++ b/packages/@n8n/decorators/src/index.ts @@ -1,4 +1,5 @@ export * from './controller'; +export * from './command'; export { Debounce } from './debounce'; export * from './execution-lifecycle'; export { Memoized } from './memoized'; diff --git a/packages/cli/bin/n8n b/packages/cli/bin/n8n index af3322971f..62241cdb7c 100755 --- a/packages/cli/bin/n8n +++ b/packages/cli/bin/n8n @@ -13,11 +13,6 @@ if (versionFlags.includes(process.argv.slice(-1)[0])) { process.exit(0); } -if (process.argv.length === 2) { - // When no command is given choose by default start - process.argv.push('start'); -} - const satisfies = require('semver/functions/satisfies'); const nodeVersion = process.versions.node; const { @@ -63,10 +58,7 @@ if (process.env.NODEJS_PREFER_IPV4 === 'true') { require('net').setDefaultAutoSelectFamily?.(false); (async () => { - // Collect DB entities from modules _before_ `DbConnectionOptions` is instantiated. - const { BaseCommand } = await import('../dist/commands/base-command.js'); - await new BaseCommand([], { root: __dirname }).loadModules(); - - const oclif = await import('@oclif/core'); - await oclif.execute({ dir: __dirname }); + const { Container } = await import('@n8n/di'); + const { CommandRegistry } = await import('../dist/command-registry.js'); + await Container.get(CommandRegistry).execute(); })(); diff --git a/packages/cli/package.json b/packages/cli/package.json index 1252ff49bb..3435e9c50c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,11 +4,6 @@ "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", - "oclif": { - "commands": "./dist/commands", - "helpClass": "./dist/help", - "bin": "n8n" - }, "scripts": { "clean": "rimraf dist .turbo", "typecheck": "tsc --noEmit", @@ -77,6 +72,7 @@ "@types/ws": "^8.18.1", "@types/xml2js": "catalog:", "@types/yamljs": "^0.2.31", + "@types/yargs-parser": "21.0.0", "@vvo/tzdb": "^6.141.0", "concurrently": "^8.2.0", "ioredis-mock": "^8.8.1", @@ -106,7 +102,6 @@ "@n8n/typeorm": "catalog:", "@n8n_io/ai-assistant-sdk": "catalog:", "@n8n_io/license-sdk": "2.22.0", - "@oclif/core": "4.0.7", "@rudderstack/rudder-sdk-node": "2.1.4", "@sentry/node": "catalog:", "aws4": "1.11.0", @@ -180,6 +175,7 @@ "xmllint-wasm": "3.0.1", "xss": "catalog:", "yamljs": "0.3.0", + "yargs-parser": "21.1.1", "zod": "catalog:" } } diff --git a/packages/cli/src/__tests__/command-registry.test.ts b/packages/cli/src/__tests__/command-registry.test.ts new file mode 100644 index 0000000000..4cc17af422 --- /dev/null +++ b/packages/cli/src/__tests__/command-registry.test.ts @@ -0,0 +1,166 @@ +import type { Logger, ModuleRegistry } from '@n8n/backend-common'; +import { CommandMetadata } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import { mock } from 'jest-mock-extended'; +import { z } from 'zod'; + +import { CommandRegistry } from '../command-registry'; + +jest.mock('fast-glob'); +// eslint-disable-next-line import-x/no-default-export +import glob from 'fast-glob'; + +describe('CommandRegistry', () => { + let commandRegistry: CommandRegistry; + let commandMetadata: CommandMetadata; + const moduleRegistry = mock(); + const logger = mock(); + let originalProcessArgv: string[]; + let mockProcessExit: jest.SpyInstance; + + class TestCommand { + flags: any; + + init = jest.fn(); + + run = jest.fn(); + + catch = jest.fn(); + + finally = jest.fn(); + } + + beforeEach(() => { + jest.resetAllMocks(); + + originalProcessArgv = process.argv; + mockProcessExit = jest.spyOn(process, 'exit').mockImplementation((() => {}) as any); + + (glob as unknown as jest.Mock).mockResolvedValue([]); + + commandMetadata = new CommandMetadata(); + Container.set(CommandMetadata, commandMetadata); + + commandMetadata.register('test-command', { + description: 'Test command description', + class: TestCommand, + examples: ['--example'], + flagsSchema: z.object({ + flag1: z.string().describe('Flag one description').optional(), + flag2: z.boolean().describe('Flag two description').optional(), + shortFlag: z.number().alias('s').describe('Short flag with alias').optional(), + }), + }); + + Container.set(TestCommand, new TestCommand()); + }); + + afterEach(() => { + process.argv = originalProcessArgv; + mockProcessExit.mockRestore(); + jest.resetAllMocks(); + }); + + it('should execute the specified command', async () => { + process.argv = ['node', 'n8n', 'test-command']; + + commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger); + await commandRegistry.execute(); + + expect(moduleRegistry.loadModules).toHaveBeenCalled(); + + const commandInstance = Container.get(commandMetadata.get('test-command')!.class); + expect(commandInstance.init).toHaveBeenCalled(); + expect(commandInstance.run).toHaveBeenCalled(); + expect(commandInstance.finally).toHaveBeenCalledWith(undefined); + }); + + it('should handle command errors', async () => { + process.argv = ['node', 'n8n', 'test-command']; + + const error = new Error('Test error'); + const commandClass = commandMetadata.get('test-command')!.class; + const commandInstance = Container.get(commandClass); + commandInstance.run = jest.fn().mockRejectedValue(error); + + commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger); + await commandRegistry.execute(); + + expect(commandInstance.catch).toHaveBeenCalledWith(error); + expect(commandInstance.finally).toHaveBeenCalledWith(error); + }); + + 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); + await commandRegistry.execute(); + + const commandInstance = Container.get(commandMetadata.get('test-command')!.class); + expect(commandInstance.flags).toEqual({ + flag1: 'value1', + flag2: true, + shortFlag: 123, + }); + }); + + it('should handle alias flags', async () => { + process.argv = ['node', 'n8n', 'test-command', '--flag1', 'value1', '-s', '123']; + + commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger); + await commandRegistry.execute(); + + const commandInstance = Container.get(commandMetadata.get('test-command')!.class); + expect(commandInstance.flags).toEqual({ + flag1: 'value1', + shortFlag: 123, + }); + }); + + it('should exit with error when command not found', async () => { + process.argv = ['node', 'n8n', 'non-existent-command']; + + commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger); + await commandRegistry.execute(); + + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('not found')); + expect(mockProcessExit).toHaveBeenCalledWith(1); + }); + + it('should display help when --help flag is used', async () => { + process.argv = ['node', 'n8n', 'test-command', '--help']; + + commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger); + await commandRegistry.execute(); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('USAGE')); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('FLAGS')); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('DESCRIPTION')); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('EXAMPLES')); + expect(mockProcessExit).toHaveBeenCalledWith(0); + }); + + it('should list all commands when global help is requested', async () => { + process.argv = ['node', 'n8n', '--help']; + + commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger); + await commandRegistry.execute(); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Available commands')); + expect(mockProcessExit).toHaveBeenCalledWith(0); + }); + + it('should display proper command usage with printCommandUsage', () => { + process.argv = ['node', 'n8n', 'test-command']; + + commandRegistry = new CommandRegistry(commandMetadata, moduleRegistry, logger); + const commandEntry = commandMetadata.get('test-command')!; + commandRegistry.printCommandUsage(commandEntry); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('USAGE')); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('test-command')); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('--flag1 ')); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('-s, --shortFlag ')); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Flag one description')); + }); +}); diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts new file mode 100644 index 0000000000..a3f894e392 --- /dev/null +++ b/packages/cli/src/command-registry.ts @@ -0,0 +1,184 @@ +import { 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'; + +/** + * Registry that manages CLI commands, their execution, and metadata. + * Handles command discovery, flag parsing, and execution lifecycle. + */ +@Service() +export class CommandRegistry { + private commandName: string; + + private readonly argv: argvParser.Arguments; + + constructor( + private readonly commandMetadata: CommandMetadata, + private readonly moduleRegistry: ModuleRegistry, + private readonly logger: Logger, + ) { + this.argv = argvParser(process.argv.slice(2)); + 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 {} + + // 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 (this.argv.help || this.argv.h) { + this.printCommandUsage(commandEntry); + return process.exit(0); + } + + const command = Container.get(commandEntry.class); + command.flags = this.parseFlags(commandEntry); + + 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); + } + } + + 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', { + 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 += ' '; + } + + 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); + } +} diff --git a/packages/cli/src/commands/__tests__/community-node.test.ts b/packages/cli/src/commands/__tests__/community-node.test.ts index 63892b5bea..fedcec0b8c 100644 --- a/packages/cli/src/commands/__tests__/community-node.test.ts +++ b/packages/cli/src/commands/__tests__/community-node.test.ts @@ -1,7 +1,6 @@ import { type InstalledNodes } from '@n8n/db'; import { type CredentialsEntity } from '@n8n/db'; import { type User } from '@n8n/db'; -import { type Config } from '@oclif/core'; import { mock } from 'jest-mock-extended'; import { CommunityNode } from '../community-node'; @@ -9,8 +8,7 @@ import { CommunityNode } from '../community-node'; describe('uninstallCredential', () => { const userId = '1234'; - const config: Config = mock(); - const communityNode = new CommunityNode(['--uninstall', '--credential', 'evolutionApi'], config); + const communityNode = new CommunityNode(); beforeEach(() => { communityNode.deleteCredential = jest.fn(); @@ -31,9 +29,8 @@ describe('uninstallCredential', () => { const user = mock(); const credentials = [credential]; - communityNode.parseFlags = jest.fn().mockReturnValue({ - flags: { credential: credentialType, uninstall: true, userId }, - }); + // @ts-expect-error Protected property + communityNode.flags = { credential: credentialType, uninstall: true, userId }; communityNode.findCredentialsByType = jest.fn().mockReturnValue(credentials); communityNode.findUserById = jest.fn().mockReturnValue(user); @@ -59,9 +56,8 @@ describe('uninstallCredential', () => { const credential = mock(); credential.id = '666'; - communityNode.parseFlags = jest.fn().mockReturnValue({ - flags: { credential: credentialType, uninstall: true, userId }, - }); + // @ts-expect-error Protected property + communityNode.flags = { credential: credentialType, uninstall: true, userId }; communityNode.findUserById = jest.fn().mockReturnValue(null); const deleteCredential = jest.spyOn(communityNode, 'deleteCredential'); @@ -83,9 +79,8 @@ describe('uninstallCredential', () => { const credential = mock(); credential.id = '666'; - communityNode.parseFlags = jest.fn().mockReturnValue({ - flags: { credential: credentialType, uninstall: true, userId }, - }); + // @ts-expect-error Protected property + communityNode.flags = { credential: credentialType, uninstall: true, userId }; communityNode.findUserById = jest.fn().mockReturnValue(mock()); communityNode.findCredentialsByType = jest.fn().mockReturnValue(null); @@ -116,9 +111,8 @@ describe('uninstallCredential', () => { const user = mock(); const credentials = [credential1, credential2]; - communityNode.parseFlags = jest.fn().mockReturnValue({ - flags: { credential: credentialType, uninstall: true, userId }, - }); + // @ts-expect-error Protected property + communityNode.flags = { credential: credentialType, uninstall: true, userId }; communityNode.findCredentialsByType = jest.fn().mockReturnValue(credentials); communityNode.findUserById = jest.fn().mockReturnValue(user); @@ -141,11 +135,7 @@ describe('uninstallCredential', () => { }); describe('uninstallPackage', () => { - const config: Config = mock(); - const communityNode = new CommunityNode( - ['--uninstall', '--package', 'n8n-nodes-evolution-api.evolutionApi'], - config, - ); + const communityNode = new CommunityNode(); beforeEach(() => { communityNode.removeCommunityPackage = jest.fn(); @@ -164,9 +154,8 @@ describe('uninstallPackage', () => { installedNodes: [installedNode], }; - communityNode.parseFlags = jest.fn().mockReturnValue({ - flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, - }); + // @ts-expect-error Protected property + communityNode.flags = { package: 'n8n-nodes-evolution-api', uninstall: true }; communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); @@ -196,9 +185,8 @@ describe('uninstallPackage', () => { installedNodes: [installedNode0, installedNode1], }; - communityNode.parseFlags = jest.fn().mockReturnValue({ - flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, - }); + // @ts-expect-error Protected property + communityNode.flags = { package: 'n8n-nodes-evolution-api', uninstall: true }; communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); @@ -222,9 +210,8 @@ describe('uninstallPackage', () => { }); it('should return if a package is not found', async () => { - communityNode.parseFlags = jest.fn().mockReturnValue({ - flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, - }); + // @ts-expect-error Protected property + communityNode.flags = { package: 'n8n-nodes-evolution-api', uninstall: true }; communityNode.findCommunityPackage = jest.fn().mockReturnValue(null); const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); @@ -246,9 +233,8 @@ describe('uninstallPackage', () => { installedNodes: [], }; - communityNode.parseFlags = jest.fn().mockReturnValue({ - flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, - }); + // @ts-expect-error Protected property + communityNode.flags = { package: 'n8n-nodes-evolution-api', uninstall: true }; communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); diff --git a/packages/cli/src/commands/__tests__/execute-batch.test.ts b/packages/cli/src/commands/__tests__/execute-batch.test.ts index d8692ef048..1bfa564392 100644 --- a/packages/cli/src/commands/__tests__/execute-batch.test.ts +++ b/packages/cli/src/commands/__tests__/execute-batch.test.ts @@ -5,7 +5,6 @@ import { WorkflowRepository } from '@n8n/db'; import { DbConnection } from '@n8n/db'; import { Container } from '@n8n/di'; import type { SelectQueryBuilder } from '@n8n/typeorm'; -import type { Config } from '@oclif/core'; import { mock } from 'jest-mock-extended'; import type { IRun } from 'n8n-workflow'; @@ -75,9 +74,9 @@ test('should start a task runner when task runners are enabled', async () => { }), ); - const cmd = new ExecuteBatch([], {} as Config); - // @ts-expect-error Private property - cmd.parse = jest.fn().mockResolvedValue({ flags: {} }); + const cmd = new ExecuteBatch(); + // @ts-expect-error Protected property + cmd.flags = {}; // @ts-expect-error Private property cmd.runTests = jest.fn().mockResolvedValue({ summary: { failedExecutions: [] } }); diff --git a/packages/cli/src/commands/__tests__/execute.test.ts b/packages/cli/src/commands/__tests__/execute.test.ts index 0e443c6e9a..62f72c023b 100644 --- a/packages/cli/src/commands/__tests__/execute.test.ts +++ b/packages/cli/src/commands/__tests__/execute.test.ts @@ -4,7 +4,6 @@ import type { User, WorkflowEntity } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db'; import { DbConnection } from '@n8n/db'; import { Container } from '@n8n/di'; -import type { Config } from '@oclif/core'; import { mock } from 'jest-mock-extended'; import type { IRun } from 'n8n-workflow'; @@ -69,9 +68,9 @@ test('should start a task runner when task runners are enabled', async () => { }), ); - const cmd = new Execute([], {} as Config); - // @ts-expect-error Private property - cmd.parse = jest.fn().mockResolvedValue({ flags: { id: '123' } }); + const cmd = new Execute(); + // @ts-expect-error Protected property + cmd.flags = { id: '123' }; // act diff --git a/packages/cli/src/commands/audit.ts b/packages/cli/src/commands/audit.ts index 547a694c41..9917320b54 100644 --- a/packages/cli/src/commands/audit.ts +++ b/packages/cli/src/commands/audit.ts @@ -1,38 +1,35 @@ import { SecurityConfig } from '@n8n/config'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; import { UserError } from 'n8n-workflow'; +import z from 'zod'; import { RISK_CATEGORIES } from '@/security-audit/constants'; import type { Risk } from '@/security-audit/types'; import { BaseCommand } from './base-command'; -export class SecurityAudit extends BaseCommand { - static description = 'Generate a security audit report for this n8n instance'; - - static examples = [ - '$ n8n audit', - '$ n8n audit --categories=database,credentials', - '$ n8n audit --days-abandoned-workflow=10', - ]; - - static flags = { - help: Flags.help({ char: 'h' }), - categories: Flags.string({ - default: RISK_CATEGORIES.join(','), - description: 'Comma-separated list of categories to include in the audit', - }), - - 'days-abandoned-workflow': Flags.integer({ - default: Container.get(SecurityConfig).daysAbandonedWorkflow, - description: 'Days for a workflow to be considered abandoned if not executed', - }), - }; +const flagsSchema = z.object({ + categories: z + .string() + .default(RISK_CATEGORIES.join(',')) + .describe('Comma-separated list of categories to include in the audit'), + 'days-abandoned-workflow': z + .number() + .int() + .default(Container.get(SecurityConfig).daysAbandonedWorkflow) + .describe('Days for a workflow to be considered abandoned if not executed'), +}); +@Command({ + name: 'audit', + description: 'Generate a security audit report for this n8n instance', + examples: ['', '--categories=database,credentials', '--days-abandoned-workflow=10'], + flagsSchema, +}) +export class SecurityAudit extends BaseCommand> { async run() { - const { flags: auditFlags } = await this.parse(SecurityAudit); - + const { flags: auditFlags } = this; const categories = auditFlags.categories?.split(',').filter((c): c is Risk.Category => c !== '') ?? RISK_CATEGORIES; diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 83217cad19..6cac0c2aac 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -11,7 +11,6 @@ import { GlobalConfig } from '@n8n/config'; import { LICENSE_FEATURES } from '@n8n/constants'; import { DbConnection } from '@n8n/db'; import { Container } from '@n8n/di'; -import { Command, Errors } from '@oclif/core'; import { BinaryDataConfig, BinaryDataService, @@ -20,7 +19,7 @@ import { DataDeduplicationService, ErrorReporter, } from 'n8n-core'; -import { ensureError, sleep, UserError } from 'n8n-workflow'; +import { ensureError, sleep, UnexpectedError, UserError } from 'n8n-workflow'; import type { AbstractServer } from '@/abstract-server'; import config from '@/config'; @@ -39,7 +38,9 @@ import { PostHogClient } from '@/posthog'; import { ShutdownService } from '@/shutdown/shutdown.service'; import { WorkflowHistoryManager } from '@/workflows/workflow-history.ee/workflow-history-manager.ee'; -export abstract class BaseCommand extends Command { +export abstract class BaseCommand { + readonly flags: F; + protected logger = Container.get(Logger); protected dbConnection: DbConnection; @@ -76,10 +77,6 @@ export abstract class BaseCommand extends Command { /** Whether to init task runner (if enabled). */ protected needsTaskRunner = false; - protected async loadModules() { - await this.moduleRegistry.loadModules(); - } - async init(): Promise { this.dbConnection = Container.get(DbConnection); this.errorReporter = Container.get(ErrorReporter); @@ -175,6 +172,14 @@ export abstract class BaseCommand extends Command { process.exit(1); } + protected log(message: string) { + this.logger.info(message); + } + + protected error(message: string) { + throw new UnexpectedError(message); + } + async initObjectStoreService() { const binaryDataConfig = Container.get(BinaryDataConfig); const isSelected = binaryDataConfig.mode === 's3'; @@ -193,7 +198,7 @@ export abstract class BaseCommand extends Command { this.logger.error( 'No license found for S3 storage. \n Either set `N8N_DEFAULT_BINARY_DATA_MODE` to something else, or upgrade to a license that supports this feature.', ); - return this.exit(1); + return process.exit(1); } this.logger.debug('License found for external storage - Initializing object store service'); @@ -264,13 +269,12 @@ export abstract class BaseCommand extends Command { async finally(error: Error | undefined) { if (error?.message) this.logger.error(error.message); - if (inTest || this.id === 'start') return; + if (inTest || this.constructor.name === 'Start') return; if (this.dbConnection.connectionState.connected) { await sleep(100); // give any in-flight query some time to finish await this.dbConnection.close(); } - const exitCode = error instanceof Errors.ExitError ? error.oclif.exit : error ? 1 : 0; - this.exit(exitCode); + process.exit(); } protected onTerminationSignal(signal: string) { diff --git a/packages/cli/src/commands/community-node.ts b/packages/cli/src/commands/community-node.ts index 94abf0be36..c9b7ff8c8c 100644 --- a/packages/cli/src/commands/community-node.ts +++ b/packages/cli/src/commands/community-node.ts @@ -1,45 +1,43 @@ import { type InstalledNodes, type InstalledPackages, type User } from '@n8n/db'; import { CredentialsRepository, InstalledNodesRepository, UserRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; +import { z } from 'zod'; import { CredentialsService } from '@/credentials/credentials.service'; import { CommunityPackagesService } from '@/services/community-packages.service'; import { BaseCommand } from './base-command'; -export class CommunityNode extends BaseCommand { - static description = '\nUninstall a community node and its credentials'; - - static examples = [ - '$ n8n community-node --uninstall --package n8n-nodes-evolution-api', - '$ n8n community-node --uninstall --credential evolutionApi --userId 1234', - ]; - - static flags = { - help: Flags.help({ char: 'h' }), - uninstall: Flags.boolean({ - description: 'Uninstalls the node', - }), - package: Flags.string({ - description: 'Package name of the community node.', - }), - credential: Flags.string({ - description: - "Type of the credential.\nGet this value by visiting the node's .credential.ts file and getting the value of `name`", - }), - userId: Flags.string({ - description: - 'The ID of the user who owns the credential.\nOn self-hosted, query the database.\nOn cloud, query the API with your API key', - }), - }; - - async init() { - await super.init(); - } +const flagsSchema = z.object({ + uninstall: z.boolean().describe('Uninstalls the node').optional(), + package: z.string().describe('Package name of the community node.').optional(), + credential: z + .string() + .describe( + "Type of the credential.\nGet this value by visiting the node's .credential.ts file and getting the value of `name`", + ) + .optional(), + userId: z + .string() + .describe( + 'The ID of the user who owns the credential.\nOn self-hosted, query the database.\nOn cloud, query the API with your API key', + ) + .optional(), +}); +@Command({ + name: 'community-node', + description: 'Uninstall a community node and its credentials', + examples: [ + '--uninstall --package n8n-nodes-evolution-api', + '--uninstall --credential evolutionApi --userId 1234', + ], + flagsSchema, +}) +export class CommunityNode extends BaseCommand> { async run() { - const { flags } = await this.parseFlags(); + const { flags } = this; const packageName = flags.package; const credentialType = flags.credential; @@ -139,10 +137,6 @@ export class CommunityNode extends BaseCommand { await Container.get(CommunityPackagesService).executeNpmCommand('npm prune'); } - async parseFlags() { - return await this.parse(CommunityNode); - } - async deleteCommunityNode(node: InstalledNodes) { return await Container.get(InstalledNodesRepository).delete({ type: node.type, diff --git a/packages/cli/src/commands/db/revert.ts b/packages/cli/src/commands/db/revert.ts index 7abe973e3c..2583d956d1 100644 --- a/packages/cli/src/commands/db/revert.ts +++ b/packages/cli/src/commands/db/revert.ts @@ -1,12 +1,12 @@ import { Logger } from '@n8n/backend-common'; import type { Migration } from '@n8n/db'; import { wrapMigration, DbConnectionOptions } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { DataSourceOptions as ConnectionOptions } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { MigrationExecutor, DataSource as Connection } from '@n8n/typeorm'; -import { Command, Flags } from '@oclif/core'; // This function is extracted to make it easier to unit test it. // Mocking turned into a mess due to this command using typeorm and the db @@ -59,22 +59,14 @@ export async function main( await connection.destroy(); } -export class DbRevertMigrationCommand extends Command { - static description = 'Revert last database migration'; - - static examples = ['$ n8n db:revert']; - - static flags = { - help: Flags.help({ char: 'h' }), - }; - - protected logger = Container.get(Logger); - +@Command({ + name: 'db:revert', + description: 'Revert last database migration', +}) +export class DbRevertMigrationCommand { private connection: Connection; - async init() { - await this.parse(DbRevertMigrationCommand); - } + constructor(private readonly logger: Logger) {} async run() { const connectionOptions: ConnectionOptions = { @@ -104,6 +96,6 @@ export class DbRevertMigrationCommand extends Command { protected async finally(error: Error | undefined) { if (this.connection?.isInitialized) await this.connection.destroy(); - this.exit(error ? 1 : 0); + process.exit(error ? 1 : 0); } } diff --git a/packages/cli/src/commands/execute-batch.ts b/packages/cli/src/commands/execute-batch.ts index 77f11dac1e..b0c2be1aaf 100644 --- a/packages/cli/src/commands/execute-batch.ts +++ b/packages/cli/src/commands/execute-batch.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-loop-func */ import type { User } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; import fs from 'fs'; import { diff } from 'json-diff'; import pick from 'lodash/pick'; @@ -10,6 +10,7 @@ import type { IRun, ITaskData, IWorkflowBase, IWorkflowExecutionDataProcess } fr import { jsonParse, UnexpectedError } from 'n8n-workflow'; import os from 'os'; import { sep } from 'path'; +import { z } from 'zod'; import { ActiveExecutions } from '@/active-executions'; import { OwnershipService } from '@/services/ownership.service'; @@ -35,9 +36,81 @@ interface ISkipList { ticketReference: string; } -export class ExecuteBatch extends BaseCommand { - static description = '\nExecutes multiple workflows once'; +const flagsSchema = z.object({ + debug: z + .boolean() + .describe('Toggles on displaying all errors and debug messages.') + .default(false), + ids: z + .string() + .describe( + 'Specifies workflow IDs to get executed, separated by a comma or a file containing the ids', + ) + .optional(), + concurrency: z + .number() + .int() + .default(1) + .describe('How many workflows can run in parallel. Defaults to 1 which means no concurrency.'), + output: z + .string() + .describe( + 'Enable execution saving, You must inform an existing folder to save execution via this param', + ) + .optional(), + snapshot: z + .string() + .describe( + 'Enables snapshot saving. You must inform an existing folder to save snapshots via this param.', + ) + .optional(), + compare: z + .string() + .describe( + 'Compares current execution with an existing snapshot. You must inform an existing folder where the snapshots are saved.', + ) + .optional(), + shallow: z + .boolean() + .describe( + 'Compares only if attributes output from node are the same, with no regards to nested JSON objects.', + ) + .optional(), + githubWorkflow: z + .boolean() + .describe( + 'Enables more lenient comparison for GitHub workflows. This is useful for reducing false positives when comparing Test workflows.', + ) + .optional(), + skipList: z + .string() + .describe('File containing a comma separated list of workflow IDs to skip.') + .optional(), + retries: z + .number() + .int() + .default(1) + .describe('Retries failed workflows up to N tries. Default is 1. Set 0 to disable.'), + shortOutput: z + .boolean() + .describe('Omits the full execution information from output, displaying only summary.') + .optional(), +}); +@Command({ + name: 'execute-batch', + description: 'Executes multiple workflows once', + examples: [ + '', + '--concurrency=10 --skipList=/data/skipList.json', + '--debug --output=/data/output.json', + '--ids=10,13,15 --shortOutput', + '--snapshot=/data/snapshots --shallow', + '--compare=/data/previousExecutionData --retries=2', + ], + flagsSchema, +}) +export class ExecuteBatch extends BaseCommand> { static cancelled = false; static workflowExecutionsProgress: IWorkflowExecutionProgress[][]; @@ -58,63 +131,6 @@ export class ExecuteBatch extends BaseCommand { static instanceOwner: User; - static examples = [ - '$ n8n executeBatch', - '$ n8n executeBatch --concurrency=10 --skipList=/data/skipList.json', - '$ n8n executeBatch --debug --output=/data/output.json', - '$ n8n executeBatch --ids=10,13,15 --shortOutput', - '$ n8n executeBatch --snapshot=/data/snapshots --shallow', - '$ n8n executeBatch --compare=/data/previousExecutionData --retries=2', - ]; - - static flags = { - help: Flags.help({ char: 'h' }), - debug: Flags.boolean({ - description: 'Toggles on displaying all errors and debug messages.', - }), - ids: Flags.string({ - description: - 'Specifies workflow IDs to get executed, separated by a comma or a file containing the ids', - }), - concurrency: Flags.integer({ - default: 1, - description: - 'How many workflows can run in parallel. Defaults to 1 which means no concurrency.', - }), - output: Flags.string({ - description: - 'Enable execution saving, You must inform an existing folder to save execution via this param', - }), - snapshot: Flags.string({ - description: - 'Enables snapshot saving. You must inform an existing folder to save snapshots via this param.', - }), - compare: Flags.string({ - description: - 'Compares current execution with an existing snapshot. You must inform an existing folder where the snapshots are saved.', - }), - shallow: Flags.boolean({ - description: - 'Compares only if attributes output from node are the same, with no regards to nested JSON objects.', - }), - - githubWorkflow: Flags.boolean({ - description: - 'Enables more lenient comparison for GitHub workflows. This is useful for reducing false positives when comparing Test workflows.', - }), - - skipList: Flags.string({ - description: 'File containing a comma separated list of workflow IDs to skip.', - }), - retries: Flags.integer({ - description: 'Retries failed workflows up to N tries. Default is 1. Set 0 to disable.', - default: 1, - }), - shortOutput: Flags.boolean({ - description: 'Omits the full execution information from output, displaying only summary.', - }), - }; - static aliases = ['executeBatch']; override needsCommunityPackages = true; @@ -182,7 +198,7 @@ export class ExecuteBatch extends BaseCommand { // eslint-disable-next-line complexity async run() { - const { flags } = await this.parse(ExecuteBatch); + const { flags } = this; ExecuteBatch.debug = flags.debug; ExecuteBatch.concurrency = flags.concurrency || 1; @@ -345,7 +361,7 @@ export class ExecuteBatch extends BaseCommand { await this.stopProcess(true); if (results.summary.failedExecutions > 0) { - this.exit(1); + process.exit(1); } } diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts index 910191d75b..2ef77d4c56 100644 --- a/packages/cli/src/commands/execute.ts +++ b/packages/cli/src/commands/execute.ts @@ -1,8 +1,9 @@ import { WorkflowRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; import type { IWorkflowBase, IWorkflowExecutionDataProcess } from 'n8n-workflow'; import { ExecutionBaseError, UnexpectedError, UserError } from 'n8n-workflow'; +import { z } from 'zod'; import { ActiveExecutions } from '@/active-executions'; import { OwnershipService } from '@/services/ownership.service'; @@ -12,21 +13,20 @@ import { WorkflowRunner } from '@/workflow-runner'; import { BaseCommand } from './base-command'; import config from '../config'; -export class Execute extends BaseCommand { - static description = '\nExecutes a given workflow'; - - static examples = ['$ n8n execute --id=5']; - - static flags = { - help: Flags.help({ char: 'h' }), - id: Flags.string({ - description: 'id of the workflow to execute', - }), - rawOutput: Flags.boolean({ - description: 'Outputs only JSON data, with no other text', - }), - }; +const flagsSchema = z.object({ + id: z.string().describe('id of the workflow to execute').optional(), + rawOutput: z.boolean().describe('Outputs only JSON data, with no other text').optional(), + /**@deprecated */ + file: z.string().describe('DEPRECATED: Please use --id instead').optional(), +}); +@Command({ + name: 'execute', + description: 'Executes a given workflow', + examples: ['--id=5'], + flagsSchema, +}) +export class Execute extends BaseCommand> { override needsCommunityPackages = true; override needsTaskRunner = true; @@ -39,7 +39,7 @@ export class Execute extends BaseCommand { } async run() { - const { flags } = await this.parse(Execute); + const { flags } = this; if (!flags.id) { this.logger.info('"--id" has to be set!'); diff --git a/packages/cli/src/commands/export/credentials.ts b/packages/cli/src/commands/export/credentials.ts index a67080a017..68ba624e05 100644 --- a/packages/cli/src/commands/export/credentials.ts +++ b/packages/cli/src/commands/export/credentials.ts @@ -1,59 +1,62 @@ import type { ICredentialsDb } from '@n8n/db'; import { CredentialsRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; import fs from 'fs'; import { Credentials } from 'n8n-core'; import { UserError } from 'n8n-workflow'; import path from 'path'; +import z from 'zod'; import type { ICredentialsDecryptedDb } from '@/interfaces'; import { BaseCommand } from '../base-command'; -export class ExportCredentialsCommand extends BaseCommand { - static description = 'Export credentials'; - - static examples = [ - '$ n8n export:credentials --all', - '$ n8n export:credentials --id=5 --output=file.json', - '$ n8n export:credentials --all --output=backups/latest.json', - '$ n8n export:credentials --backup --output=backups/latest/', - '$ n8n export:credentials --all --decrypted --output=backups/decrypted.json', - ]; - - static flags = { - help: Flags.help({ char: 'h' }), - all: Flags.boolean({ - description: 'Export all credentials', - }), - backup: Flags.boolean({ - description: - 'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.', - }), - id: Flags.string({ - description: 'The ID of the credential to export', - }), - output: Flags.string({ - char: 'o', - description: 'Output file name or directory if using separate files', - }), - pretty: Flags.boolean({ - description: 'Format the output in an easier to read fashion', - }), - separate: Flags.boolean({ - description: - 'Exports one file per credential (useful for versioning). Must inform a directory via --output.', - }), - decrypted: Flags.boolean({ - description: - 'Exports data decrypted / in plain text. ALL SENSITIVE INFORMATION WILL BE VISIBLE IN THE FILES. Use to migrate from a installation to another that have a different secret key (in the config file).', - }), - }; +const flagsSchema = z.object({ + all: z.boolean().describe('Export all credentials').optional(), + backup: z + .boolean() + .describe( + 'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.', + ) + .optional(), + id: z.string().describe('The ID of the credential to export').optional(), + output: z + .string() + .alias('o') + .describe('Output file name or directory if using separate files') + .optional(), + pretty: z.boolean().describe('Format the output in an easier to read fashion').optional(), + separate: z + .boolean() + .describe( + 'Exports one file per credential (useful for versioning). Must inform a directory via --output.', + ) + .optional(), + decrypted: z + .boolean() + .describe( + 'Exports data decrypted / in plain text. ALL SENSITIVE INFORMATION WILL BE VISIBLE IN THE FILES. Use to migrate from a installation to another that have a different secret key (in the config file).', + ) + .optional(), +}); +@Command({ + name: 'export:credentials', + description: 'Export credentials', + examples: [ + '--all', + '--id=5 --output=file.json', + '--all --output=backups/latest.json', + '--backup --output=backups/latest/', + '--all --decrypted --output=backups/decrypted.json', + ], + flagsSchema, +}) +export class ExportCredentialsCommand extends BaseCommand> { // eslint-disable-next-line complexity async run() { - const { flags } = await this.parse(ExportCredentialsCommand); + const { flags } = this; if (flags.backup) { flags.all = true; @@ -133,7 +136,7 @@ export class ExportCredentialsCommand extends BaseCommand { for (i = 0; i < credentials.length; i++) { fileContents = JSON.stringify(credentials[i], null, flags.pretty ? 2 : undefined); const filename = `${ - (flags.output!.endsWith(path.sep) ? flags.output! : flags.output + path.sep) + + (flags.output!.endsWith(path.sep) ? flags.output : flags.output + path.sep) + credentials[i].id }.json`; fs.writeFileSync(filename, fileContents); diff --git a/packages/cli/src/commands/export/workflow.ts b/packages/cli/src/commands/export/workflow.ts index f87cf2500f..308b4709c4 100644 --- a/packages/cli/src/commands/export/workflow.ts +++ b/packages/cli/src/commands/export/workflow.ts @@ -1,50 +1,51 @@ import { WorkflowRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; import fs from 'fs'; import { UserError } from 'n8n-workflow'; import path from 'path'; +import { z } from 'zod'; import { BaseCommand } from '../base-command'; -export class ExportWorkflowsCommand extends BaseCommand { - static description = 'Export workflows'; - - static examples = [ - '$ n8n export:workflow --all', - '$ n8n export:workflow --id=5 --output=file.json', - '$ n8n export:workflow --all --output=backups/latest/', - '$ n8n export:workflow --backup --output=backups/latest/', - ]; - - static flags = { - help: Flags.help({ char: 'h' }), - all: Flags.boolean({ - description: 'Export all workflows', - }), - backup: Flags.boolean({ - description: - 'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.', - }), - id: Flags.string({ - description: 'The ID of the workflow to export', - }), - output: Flags.string({ - char: 'o', - description: 'Output file name or directory if using separate files', - }), - pretty: Flags.boolean({ - description: 'Format the output in an easier to read fashion', - }), - separate: Flags.boolean({ - description: - 'Exports one file per workflow (useful for versioning). Must inform a directory via --output.', - }), - }; +const flagsSchema = z.object({ + all: z.boolean().describe('Export all workflows').optional(), + backup: z + .boolean() + .describe( + 'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.', + ) + .optional(), + id: z.string().describe('The ID of the workflow to export').optional(), + output: z + .string() + .alias('o') + .describe('Output file name or directory if using separate files') + .optional(), + pretty: z.boolean().describe('Format the output in an easier to read fashion').optional(), + separate: z + .boolean() + .describe( + 'Exports one file per workflow (useful for versioning). Must inform a directory via --output.', + ) + .optional(), +}); +@Command({ + name: 'export:workflow', + description: 'Export workflows', + examples: [ + '--all', + '--id=5 --output=file.json', + '--all --output=backups/latest/', + '--backup --output=backups/latest/', + ], + flagsSchema, +}) +export class ExportWorkflowsCommand extends BaseCommand> { // eslint-disable-next-line complexity async run() { - const { flags } = await this.parse(ExportWorkflowsCommand); + const { flags } = this; if (flags.backup) { flags.all = true; @@ -89,7 +90,7 @@ export class ExportWorkflowsCommand extends BaseCommand { this.logger.error(e.message); this.logger.error(e.stack!); } - this.exit(1); + process.exit(1); } } else if (flags.output) { if (fs.existsSync(flags.output)) { @@ -115,7 +116,7 @@ export class ExportWorkflowsCommand extends BaseCommand { for (i = 0; i < workflows.length; i++) { fileContents = JSON.stringify(workflows[i], null, flags.pretty ? 2 : undefined); const filename = `${ - (flags.output!.endsWith(path.sep) ? flags.output! : flags.output + path.sep) + + (flags.output!.endsWith(path.sep) ? flags.output : flags.output + path.sep) + workflows[i].id }.json`; fs.writeFileSync(filename, fileContents); diff --git a/packages/cli/src/commands/import/credentials.ts b/packages/cli/src/commands/import/credentials.ts index 49fa6a81b3..12c68110df 100644 --- a/packages/cli/src/commands/import/credentials.ts +++ b/packages/cli/src/commands/import/credentials.ts @@ -1,50 +1,56 @@ import { CredentialsEntity, Project, User, SharedCredentials, ProjectRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import type { EntityManager } from '@n8n/typeorm'; -import { Flags } from '@oclif/core'; import glob from 'fast-glob'; import fs from 'fs'; import { Cipher } from 'n8n-core'; import type { ICredentialsEncrypted } from 'n8n-workflow'; import { jsonParse, UserError } from 'n8n-workflow'; +import { z } from 'zod'; import { UM_FIX_INSTRUCTION } from '@/constants'; import { BaseCommand } from '../base-command'; -export class ImportCredentialsCommand extends BaseCommand { - static description = 'Import credentials'; - - static examples = [ - '$ n8n import:credentials --input=file.json', - '$ n8n import:credentials --separate --input=backups/latest/', - '$ n8n import:credentials --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', - '$ n8n import:credentials --input=file.json --projectId=Ox8O54VQrmBrb4qL', - '$ n8n import:credentials --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', - ]; - - static flags = { - help: Flags.help({ char: 'h' }), - input: Flags.string({ - char: 'i', - description: 'Input file name or directory if --separate is used', - }), - separate: Flags.boolean({ - description: 'Imports *.json files from directory provided by --input', - }), - userId: Flags.string({ - description: 'The ID of the user to assign the imported credentials to', - }), - projectId: Flags.string({ - description: 'The ID of the project to assign the imported credential to', - }), - }; +const flagsSchema = z.object({ + input: z + .string() + .alias('i') + .describe('Input file name or directory if --separate is used') + .optional(), + separate: z + .boolean() + .default(false) + .describe('Imports *.json files from directory provided by --input'), + userId: z + .string() + .describe('The ID of the user to assign the imported credentials to') + .optional(), + projectId: z + .string() + .describe('The ID of the project to assign the imported credential to') + .optional(), +}); +@Command({ + name: 'import:credentials', + description: 'Import credentials', + examples: [ + '--input=file.json', + '--separate --input=backups/latest/', + '--input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '--input=file.json --projectId=Ox8O54VQrmBrb4qL', + '--separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + ], + flagsSchema, +}) +export class ImportCredentialsCommand extends BaseCommand> { private transactionManager: EntityManager; async run(): Promise { - const { flags } = await this.parse(ImportCredentialsCommand); + const { flags } = this; if (!flags.input) { this.logger.info('An input file or directory with --input must be provided'); diff --git a/packages/cli/src/commands/import/workflow.ts b/packages/cli/src/commands/import/workflow.ts index 98e945a072..0327e134c9 100644 --- a/packages/cli/src/commands/import/workflow.ts +++ b/packages/cli/src/commands/import/workflow.ts @@ -6,12 +6,13 @@ import { WorkflowRepository, UserRepository, } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; import glob from 'fast-glob'; import fs from 'fs'; import type { IWorkflowBase, WorkflowId } from 'n8n-workflow'; import { jsonParse, UserError } from 'n8n-workflow'; +import { z } from 'zod'; import { UM_FIX_INSTRUCTION } from '@/constants'; import type { IWorkflowToImport } from '@/interfaces'; @@ -33,36 +34,38 @@ function assertHasWorkflowsToImport( } } -export class ImportWorkflowsCommand extends BaseCommand { - static description = 'Import workflows'; - - static examples = [ - '$ n8n import:workflow --input=file.json', - '$ n8n import:workflow --separate --input=backups/latest/', - '$ n8n import:workflow --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', - '$ n8n import:workflow --input=file.json --projectId=Ox8O54VQrmBrb4qL', - '$ n8n import:workflow --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', - ]; - - static flags = { - help: Flags.help({ char: 'h' }), - input: Flags.string({ - char: 'i', - description: 'Input file name or directory if --separate is used', - }), - separate: Flags.boolean({ - description: 'Imports *.json files from directory provided by --input', - }), - userId: Flags.string({ - description: 'The ID of the user to assign the imported workflows to', - }), - projectId: Flags.string({ - description: 'The ID of the project to assign the imported workflows to', - }), - }; +const flagsSchema = z.object({ + input: z + .string() + .alias('i') + .describe('Input file name or directory if --separate is used') + .optional(), + separate: z + .boolean() + .describe('Imports *.json files from directory provided by --input') + .default(false), + userId: z.string().describe('The ID of the user to assign the imported workflows to').optional(), + projectId: z + .string() + .describe('The ID of the project to assign the imported workflows to') + .optional(), +}); +@Command({ + name: 'import:workflow', + description: 'Import workflows', + examples: [ + '--input=file.json', + '--separate --input=backups/latest/', + '--input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '--input=file.json --projectId=Ox8O54VQrmBrb4qL', + '--separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + ], + flagsSchema, +}) +export class ImportWorkflowsCommand extends BaseCommand> { async run(): Promise { - const { flags } = await this.parse(ImportWorkflowsCommand); + const { flags } = this; if (!flags.input) { this.logger.info('An input file or directory with --input must be provided'); diff --git a/packages/cli/src/commands/ldap/reset.ts b/packages/cli/src/commands/ldap/reset.ts index 33cd3d4e85..56831f674a 100644 --- a/packages/cli/src/commands/ldap/reset.ts +++ b/packages/cli/src/commands/ldap/reset.ts @@ -9,11 +9,12 @@ import { SharedWorkflowRepository, UserRepository, } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; -import { Flags } from '@oclif/core'; import { UserError } from 'n8n-workflow'; +import { z } from 'zod'; import { UM_FIX_INSTRUCTION } from '@/constants'; import { CredentialsService } from '@/credentials/credentials.service'; @@ -24,34 +25,41 @@ import { BaseCommand } from '../base-command'; const wrongFlagsError = 'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.'; -export class Reset extends BaseCommand { - static description = - '\nResets the database to the default ldap state.\n\nTHIS DELETES ALL LDAP MANAGED USERS.'; - - static examples = [ - '$ n8n ldap:reset --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', - '$ n8n ldap:reset --projectId=Ox8O54VQrmBrb4qL', - '$ n8n ldap:reset --deleteWorkflowsAndCredentials', - ]; - - static flags = { - help: Flags.help({ char: 'h' }), - userId: Flags.string({ - description: - 'The ID of the user to assign the workflows and credentials owned by the deleted LDAP users to', - }), - projectId: Flags.string({ - description: - 'The ID of the project to assign the workflows and credentials owned by the deleted LDAP users to', - }), - deleteWorkflowsAndCredentials: Flags.boolean({ - description: - 'Delete all workflows and credentials owned by the users that were created by the users managed via LDAP.', - }), - }; +const flagsSchema = z.object({ + userId: z + .string() + .describe( + 'The ID of the user to assign the workflows and credentials owned by the deleted LDAP users to', + ) + .optional(), + projectId: z + .string() + .describe( + 'The ID of the project to assign the workflows and credentials owned by the deleted LDAP users to', + ) + .optional(), + deleteWorkflowsAndCredentials: z + .boolean() + .describe( + 'Delete all workflows and credentials owned by the users that were created by the users managed via LDAP.', + ) + .optional(), +}); +@Command({ + name: 'ldap:reset', + description: + 'Resets the database to the default ldap state.\n\nTHIS DELETES ALL LDAP MANAGED USERS.', + examples: [ + '--userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', + '--projectId=Ox8O54VQrmBrb4qL', + '--deleteWorkflowsAndCredentials', + ], + flagsSchema, +}) +export class Reset extends BaseCommand> { async run(): Promise { - const { flags } = await this.parse(Reset); + const { flags } = this; const numberOfOptions = Number(!!flags.userId) + Number(!!flags.projectId) + diff --git a/packages/cli/src/commands/license/clear.ts b/packages/cli/src/commands/license/clear.ts index e75fefadf3..eb5ef9c4f2 100644 --- a/packages/cli/src/commands/license/clear.ts +++ b/packages/cli/src/commands/license/clear.ts @@ -1,14 +1,15 @@ +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { License } from '@/license'; import { BaseCommand } from '../base-command'; +@Command({ + name: 'license:clear', + description: 'Clear local license certificate', +}) export class ClearLicenseCommand extends BaseCommand { - static description = 'Clear local license certificate'; - - static examples = ['$ n8n license:clear']; - async run() { this.logger.info('Clearing license from database.'); diff --git a/packages/cli/src/commands/license/info.ts b/packages/cli/src/commands/license/info.ts index f99648d0d5..4b3e9d6763 100644 --- a/packages/cli/src/commands/license/info.ts +++ b/packages/cli/src/commands/license/info.ts @@ -1,14 +1,15 @@ +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { License } from '@/license'; import { BaseCommand } from '../base-command'; +@Command({ + name: 'license:info', + description: 'Print license information', +}) export class LicenseInfoCommand extends BaseCommand { - static description = 'Print license information'; - - static examples = ['$ n8n license:info']; - async run() { const license = Container.get(License); await license.init({ isCli: true }); diff --git a/packages/cli/src/commands/list/workflow.ts b/packages/cli/src/commands/list/workflow.ts index 744a2aa5d7..eaff1f407f 100644 --- a/packages/cli/src/commands/list/workflow.ts +++ b/packages/cli/src/commands/list/workflow.ts @@ -1,30 +1,27 @@ import { WorkflowRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; +import { z } from 'zod'; import { BaseCommand } from '../base-command'; -export class ListWorkflowCommand extends BaseCommand { - static description = '\nList workflows'; - - static examples = [ - '$ n8n list:workflow', - '$ n8n list:workflow --active=true --onlyId', - '$ n8n list:workflow --active=false', - ]; - - static flags = { - help: Flags.help({ char: 'h' }), - active: Flags.string({ - description: 'Filters workflows by active status. Can be true or false', - }), - onlyId: Flags.boolean({ - description: 'Outputs workflow IDs only, one per line.', - }), - }; +const flagsSchema = z.object({ + active: z + .string() + .describe('Filters workflows by active status. Can be true or false') + .optional(), + onlyId: z.boolean().describe('Outputs workflow IDs only, one per line.').default(false), +}); +@Command({ + name: 'list:workflow', + description: 'List workflows', + examples: ['', '--active=true --onlyId', '--active=false'], + flagsSchema, +}) +export class ListWorkflowCommand extends BaseCommand> { async run() { - const { flags } = await this.parse(ListWorkflowCommand); + const { flags } = this; if (flags.active !== undefined && !['true', 'false'].includes(flags.active)) { this.error('The --active flag has to be passed using true or false'); diff --git a/packages/cli/src/commands/mfa/disable.ts b/packages/cli/src/commands/mfa/disable.ts index 1a95ec3553..d7e661423f 100644 --- a/packages/cli/src/commands/mfa/disable.ts +++ b/packages/cli/src/commands/mfa/disable.ts @@ -1,27 +1,23 @@ import { UserRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; +import { z } from 'zod'; import { BaseCommand } from '../base-command'; -export class DisableMFACommand extends BaseCommand { - static description = 'Disable MFA authentication for a user'; - - static examples = ['$ n8n mfa:disable --email=johndoe@example.com']; - - static flags = { - help: Flags.help({ char: 'h' }), - email: Flags.string({ - description: 'The email of the user to disable the MFA authentication', - }), - }; - - async init() { - await super.init(); - } +const flagsSchema = z.object({ + email: z.string().describe('The email of the user to disable the MFA authentication'), +}); +@Command({ + name: 'mfa:disable', + description: 'Disable MFA authentication for a user', + examples: ['--email=johndoe@example.com'], + flagsSchema, +}) +export class DisableMFACommand extends BaseCommand> { async run(): Promise { - const { flags } = await this.parse(DisableMFACommand); + const { flags } = this; if (!flags.email) { this.logger.info('An email with --email must be provided'); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index fd48ec1126..5f54d13381 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -2,8 +2,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { LICENSE_FEATURES } from '@n8n/constants'; import { ExecutionRepository, SettingsRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; import glob from 'fast-glob'; import { createReadStream, createWriteStream, existsSync } from 'fs'; import { mkdir } from 'fs/promises'; @@ -11,6 +11,7 @@ import { jsonParse, randomString, type IWorkflowExecutionDataProcess } from 'n8n import path from 'path'; import replaceStream from 'replacestream'; import { pipeline } from 'stream/promises'; +import { z } from 'zod'; import { ActiveExecutions } from '@/active-executions'; import { ActiveWorkflowManager } from '@/active-workflow-manager'; @@ -36,32 +37,29 @@ import { BaseCommand } from './base-command'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const open = require('open'); -export class Start extends BaseCommand { - static description = 'Starts n8n. Makes Web-UI available and starts active workflows'; - - static examples = [ - '$ n8n start', - '$ n8n start --tunnel', - '$ n8n start -o', - '$ n8n start --tunnel -o', - ]; - - static flags = { - help: Flags.help({ char: 'h' }), - open: Flags.boolean({ - char: 'o', - description: 'opens the UI automatically in browser', - }), - tunnel: Flags.boolean({ - description: - 'runs the webhooks via a hooks.n8n.cloud tunnel server. Use only for testing and development!', - }), - reinstallMissingPackages: Flags.boolean({ - description: - 'Attempts to self heal n8n if packages with nodes are missing. Might drastically increase startup times.', - }), - }; +const flagsSchema = z.object({ + open: z.boolean().alias('o').describe('opens the UI automatically in browser').optional(), + tunnel: z + .boolean() + .describe( + 'runs the webhooks via a hooks.n8n.cloud tunnel server. Use only for testing and development!', + ) + .optional(), + reinstallMissingPackages: z + .boolean() + .describe( + 'Attempts to self heal n8n if packages with nodes are missing. Might drastically increase startup times.', + ) + .optional(), +}); +@Command({ + name: 'start', + description: 'Starts n8n. Makes Web-UI available and starts active workflows', + examples: ['', '--tunnel', '-o', '--tunnel -o'], + flagsSchema, +}) +export class Start extends BaseCommand> { protected activeWorkflowManager: ActiveWorkflowManager; protected server = Container.get(Server); @@ -181,7 +179,7 @@ export class Start extends BaseCommand { scopedLogger.debug(`Host ID: ${this.instanceSettings.hostId}`); } - const { flags } = await this.parse(Start); + const { flags } = this; const { communityPackages } = this.globalConfig.nodes; // cli flag overrides the config env variable if (flags.reinstallMissingPackages) { @@ -272,7 +270,7 @@ export class Start extends BaseCommand { } async run() { - const { flags } = await this.parse(Start); + const { flags } = this; // Load settings from database and set them to config. const databaseSettings = await Container.get(SettingsRepository).findBy({ diff --git a/packages/cli/src/commands/update/workflow.ts b/packages/cli/src/commands/update/workflow.ts index 3f2395bbe1..3b44f7bd31 100644 --- a/packages/cli/src/commands/update/workflow.ts +++ b/packages/cli/src/commands/update/workflow.ts @@ -1,32 +1,25 @@ import { WorkflowRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; +import { z } from 'zod'; import { BaseCommand } from '../base-command'; -export class UpdateWorkflowCommand extends BaseCommand { - static description = 'Update workflows'; - - static examples = [ - '$ n8n update:workflow --all --active=false', - '$ n8n update:workflow --id=5 --active=true', - ]; - - static flags = { - help: Flags.help({ char: 'h' }), - active: Flags.string({ - description: 'Active state the workflow/s should be set to', - }), - all: Flags.boolean({ - description: 'Operate on all workflows', - }), - id: Flags.string({ - description: 'The ID of the workflow to operate on', - }), - }; +const flagsSchema = z.object({ + active: z.string().describe('Active state the workflow/s should be set to').optional(), + all: z.boolean().describe('Operate on all workflows').optional(), + id: z.string().describe('The ID of the workflow to operate on').optional(), +}); +@Command({ + name: 'update:workflow', + description: 'Update workflows', + examples: ['--all --active=false', '--id=5 --active=true'], + flagsSchema, +}) +export class UpdateWorkflowCommand extends BaseCommand> { async run() { - const { flags } = await this.parse(UpdateWorkflowCommand); + const { flags } = this; if (!flags.all && !flags.id) { this.logger.error('Either option "--all" or "--id" have to be set!'); diff --git a/packages/cli/src/commands/user-management/reset.ts b/packages/cli/src/commands/user-management/reset.ts index 15f3e9a716..69667fc565 100644 --- a/packages/cli/src/commands/user-management/reset.ts +++ b/packages/cli/src/commands/user-management/reset.ts @@ -8,6 +8,7 @@ import { SharedWorkflowRepository, UserRepository, } from '@n8n/db'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { BaseCommand } from '../base-command'; @@ -20,11 +21,11 @@ const defaultUserProps = { role: 'global:owner', }; +@Command({ + name: 'user-management:reset', + description: 'Resets the database to the default user state', +}) export class Reset extends BaseCommand { - static description = 'Resets the database to the default user state'; - - static examples = ['$ n8n user-management:reset']; - async run(): Promise { const owner = await this.getInstanceOwner(); const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( @@ -76,6 +77,6 @@ export class Reset extends BaseCommand { async catch(error: Error): Promise { this.logger.error('Error resetting database. See log messages for details.'); this.logger.error(error.message); - this.exit(1); + process.exit(1); } } diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index 92cd196916..0aabe0fa97 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -1,5 +1,5 @@ +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags } from '@oclif/core'; import { ActiveExecutions } from '@/active-executions'; import config from '@/config'; @@ -10,15 +10,11 @@ import { WebhookServer } from '@/webhooks/webhook-server'; import { BaseCommand } from './base-command'; +@Command({ + name: 'webhook', + description: 'Starts n8n webhook process. Intercepts only production URLs.', +}) export class Webhook extends BaseCommand { - static description = 'Starts n8n webhook process. Intercepts only production URLs.'; - - static examples = ['$ n8n webhook']; - - static flags = { - help: Flags.help({ char: 'h' }), - }; - protected server = Container.get(WebhookServer); override needsCommunityPackages = true; diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 23a1dfff5d..674ef87217 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -1,6 +1,7 @@ import { inTest } from '@n8n/backend-common'; +import { Command } from '@n8n/decorators'; import { Container } from '@n8n/di'; -import { Flags, type Config } from '@oclif/core'; +import { z } from 'zod'; import config from '@/config'; import { N8N_VERSION } from '@/constants'; @@ -15,28 +16,26 @@ import type { WorkerServerEndpointsConfig } from '@/scaling/worker-server'; import { BaseCommand } from './base-command'; -export class Worker extends BaseCommand { - static description = '\nStarts a n8n worker'; - - static examples = ['$ n8n worker --concurrency=5']; - - static flags = { - help: Flags.help({ char: 'h' }), - concurrency: Flags.integer({ - default: 10, - description: 'How many jobs can run in parallel.', - }), - }; +const flagsSchema = z.object({ + concurrency: z.number().int().default(10).describe('How many jobs can run in parallel.'), +}); +@Command({ + name: 'worker', + description: 'Starts a n8n worker', + examples: ['--concurrency=5'], + flagsSchema, +}) +export class Worker extends BaseCommand> { /** * How many jobs this worker may run concurrently. * * Taken from env var `N8N_CONCURRENCY_PRODUCTION_LIMIT` if set to a value * other than -1, else taken from `--concurrency` flag. */ - concurrency: number; + private concurrency: number; - scalingService: ScalingService; + private scalingService: ScalingService; override needsCommunityPackages = true; @@ -59,12 +58,12 @@ export class Worker extends BaseCommand { await this.exitSuccessFully(); } - constructor(argv: string[], cmdConfig: Config) { + constructor() { if (config.getEnv('executions.mode') !== 'queue') { config.set('executions.mode', 'queue'); } - super(argv, cmdConfig); + super(); this.logger = this.logger.scoped('scaling'); } @@ -133,7 +132,7 @@ export class Worker extends BaseCommand { } async setConcurrency() { - const { flags } = await this.parse(Worker); + const { flags } = this; const envConcurrency = config.getEnv('executions.concurrency.productionLimit'); diff --git a/packages/cli/src/help.ts b/packages/cli/src/help.ts deleted file mode 100644 index 2e531bf8e1..0000000000 --- a/packages/cli/src/help.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Logger } from '@n8n/backend-common'; -import { Container } from '@n8n/di'; -import { Help } from '@oclif/core'; - -// oclif expects a default export -// eslint-disable-next-line import-x/no-default-export -export default class CustomHelp extends Help { - async showRootHelp() { - Container.get(Logger).info( - 'You can find up to date information about the CLI here:\nhttps://docs.n8n.io/hosting/cli-commands/', - ); - } -} diff --git a/packages/cli/src/zod-alias-support.ts b/packages/cli/src/zod-alias-support.ts new file mode 100644 index 0000000000..6cff638697 --- /dev/null +++ b/packages/cli/src/zod-alias-support.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +// Monkey-patch zod to support aliases +declare module 'zod' { + interface ZodType { + alias(this: T, aliasName: string): T; + } + interface ZodTypeDef { + _alias: string; + } +} + +z.ZodType.prototype.alias = function (this: T, aliasName: string) { + this._def._alias = aliasName; + return this; +}; diff --git a/packages/cli/test/integration/commands/credentials.cmd.test.ts b/packages/cli/test/integration/commands/credentials.cmd.test.ts index 42e44042e2..b69c3ec6d4 100644 --- a/packages/cli/test/integration/commands/credentials.cmd.test.ts +++ b/packages/cli/test/integration/commands/credentials.cmd.test.ts @@ -2,6 +2,7 @@ import { getPersonalProject, mockInstance } from '@n8n/backend-test-utils'; import { testDb } from '@n8n/backend-test-utils'; import { nanoid } from 'nanoid'; +import '@/zod-alias-support'; import { ImportCredentialsCommand } from '@/commands/import/credentials'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { setupTestCommand } from '@test-integration/utils/test-command'; diff --git a/packages/cli/test/integration/commands/import.cmd.test.ts b/packages/cli/test/integration/commands/import.cmd.test.ts index bbb3f9c47d..1d55b9ae41 100644 --- a/packages/cli/test/integration/commands/import.cmd.test.ts +++ b/packages/cli/test/integration/commands/import.cmd.test.ts @@ -3,6 +3,7 @@ import { getPersonalProject } from '@n8n/backend-test-utils'; import { getAllSharedWorkflows, getAllWorkflows } from '@n8n/backend-test-utils'; import { nanoid } from 'nanoid'; +import '@/zod-alias-support'; import { ImportWorkflowsCommand } from '@/commands/import/workflow'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { setupTestCommand } from '@test-integration/utils/test-command'; diff --git a/packages/cli/test/integration/shared/utils/test-command.ts b/packages/cli/test/integration/shared/utils/test-command.ts index 4729cb74b7..ea8f1e1140 100644 --- a/packages/cli/test/integration/shared/utils/test-command.ts +++ b/packages/cli/test/integration/shared/utils/test-command.ts @@ -1,19 +1,14 @@ import { testDb } from '@n8n/backend-test-utils'; import { mockInstance } from '@n8n/backend-test-utils'; -import type { Config } from '@oclif/core'; -import { mock } from 'jest-mock-extended'; -import type { Class } from 'n8n-core'; +import type { CommandClass } from '@n8n/decorators'; +import argvParser from 'yargs-parser'; -import type { BaseCommand } from '@/commands/base-command'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; mockInstance(MessageEventBus); -export const setupTestCommand = (Command: Class) => { - const config = mock(); - config.runHook.mockResolvedValue({ successes: [], failures: [] }); - +export const setupTestCommand = (Command: T) => { // mock SIGINT/SIGTERM registration process.once = jest.fn(); process.exit = jest.fn() as never; @@ -34,8 +29,9 @@ export const setupTestCommand = (Command: Class) => { }); const run = async (argv: string[] = []) => { - const command = new Command(argv, config); - await command.init(); + const command = new Command(); + command.flags = argvParser(argv); + await command.init?.(); await command.run(); return command; }; diff --git a/packages/core/src/errors/error-reporter.ts b/packages/core/src/errors/error-reporter.ts index 5b7a492056..79a12ef439 100644 --- a/packages/core/src/errors/error-reporter.ts +++ b/packages/core/src/errors/error-reporter.ts @@ -1,4 +1,4 @@ -import { Logger } from '@n8n/backend-common'; +import { inTest, Logger } from '@n8n/backend-common'; import type { InstanceType } from '@n8n/constants'; import { Service } from '@n8n/di'; import type { NodeOptions } from '@sentry/node'; @@ -81,6 +81,8 @@ export class ErrorReporter { serverName, releaseDate, }: ErrorReporterInitOptions) { + if (inTest) return; + process.on('uncaughtException', (error) => { this.error(error); }); diff --git a/packages/core/src/instance-settings/instance-settings.ts b/packages/core/src/instance-settings/instance-settings.ts index 84f2b26b60..2a69d9b65b 100644 --- a/packages/core/src/instance-settings/instance-settings.ts +++ b/packages/core/src/instance-settings/instance-settings.ts @@ -181,7 +181,7 @@ export class InstanceSettings { errorMessage: `Error parsing n8n-config file "${this.settingsFile}". It does not seem to be valid JSON.`, }); - if (!inTest) this.logger.info(`User settings loaded from: ${this.settingsFile}`); + if (!inTest) this.logger.debug(`User settings loaded from: ${this.settingsFile}`); const { encryptionKey, tunnelSubdomain } = settings; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bb40483c8..2332a8a621 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1274,9 +1274,6 @@ importers: '@n8n_io/license-sdk': specifier: 2.22.0 version: 2.22.0 - '@oclif/core': - specifier: 4.0.7 - version: 4.0.7 '@rudderstack/rudder-sdk-node': specifier: 2.1.4 version: 2.1.4(tslib@2.8.1) @@ -1496,6 +1493,9 @@ importers: yamljs: specifier: 0.3.0 version: 0.3.0 + yargs-parser: + specifier: 21.1.1 + version: 21.1.1 zod: specifier: 3.25.67 version: 3.25.67 @@ -1575,6 +1575,9 @@ importers: '@types/yamljs': specifier: ^0.2.31 version: 0.2.31 + '@types/yargs-parser': + specifier: 21.0.0 + version: 21.0.0 '@vvo/tzdb': specifier: ^6.141.0 version: 6.141.0