refactor(core): Overhaul commands setup. Add support for module commands (#16709)

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2025-07-01 19:14:22 +02:00
committed by GitHub
parent 346bc84093
commit 9f8d3d3bc8
41 changed files with 1061 additions and 541 deletions

View File

@@ -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);
});
});

View File

@@ -0,0 +1,20 @@
import { Service } from '@n8n/di';
import type { CommandEntry } from './types';
@Service()
export class CommandMetadata {
private readonly commands: Map<string, CommandEntry> = 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()];
}
}

View File

@@ -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);
};

View File

@@ -0,0 +1,3 @@
export { Command } from './command';
export { CommandMetadata } from './command-metadata';
export type { ICommand, CommandClass, CommandEntry } from './types';

View File

@@ -0,0 +1,28 @@
import type { Constructable } from '@n8n/di';
import type { ZodObject, ZodTypeAny } from 'zod';
type FlagsSchema = ZodObject<Record<string, ZodTypeAny>>;
export type CommandOptions = {
name: string;
description: string;
examples?: string[];
flagsSchema?: FlagsSchema;
};
export type ICommand = {
flags?: object;
init?: () => Promise<void>;
run: () => Promise<void>;
catch?: (e: Error) => Promise<void>;
finally?: (e?: Error) => Promise<void>;
};
export type CommandClass = Constructable<ICommand>;
export type CommandEntry = {
class: CommandClass;
description: string;
examples?: string[];
flagsSchema?: FlagsSchema;
};

View File

@@ -1,4 +1,5 @@
export * from './controller';
export * from './command';
export { Debounce } from './debounce';
export * from './execution-lifecycle';
export { Memoized } from './memoized';

View File

@@ -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();
})();

View File

@@ -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:"
}
}

View File

@@ -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<ModuleRegistry>();
const logger = mock<Logger>();
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 <value>'));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('-s, --shortFlag <value>'));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Flag one description'));
});
});

View File

@@ -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 += ' <value>';
}
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);
}
}

View File

@@ -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<Config>();
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<User>();
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<CredentialsEntity>();
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<CredentialsEntity>();
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<User>());
communityNode.findCredentialsByType = jest.fn().mockReturnValue(null);
@@ -116,9 +111,8 @@ describe('uninstallCredential', () => {
const user = mock<User>();
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<Config>();
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');

View File

@@ -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: [] } });

View File

@@ -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

View File

@@ -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<z.infer<typeof flagsSchema>> {
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;

View File

@@ -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<F = never> {
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<void> {
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) {

View File

@@ -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<z.infer<typeof flagsSchema>> {
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,

View File

@@ -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);
}
}

View File

@@ -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<z.infer<typeof flagsSchema>> {
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);
}
}

View File

@@ -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<z.infer<typeof flagsSchema>> {
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!');

View File

@@ -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<z.infer<typeof flagsSchema>> {
// 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);

View File

@@ -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<z.infer<typeof flagsSchema>> {
// 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);

View File

@@ -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<z.infer<typeof flagsSchema>> {
private transactionManager: EntityManager;
async run(): Promise<void> {
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');

View File

@@ -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<z.infer<typeof flagsSchema>> {
async run(): Promise<void> {
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');

View File

@@ -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<z.infer<typeof flagsSchema>> {
async run(): Promise<void> {
const { flags } = await this.parse(Reset);
const { flags } = this;
const numberOfOptions =
Number(!!flags.userId) +
Number(!!flags.projectId) +

View File

@@ -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.');

View File

@@ -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 });

View File

@@ -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<z.infer<typeof flagsSchema>> {
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');

View File

@@ -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<z.infer<typeof flagsSchema>> {
async run(): Promise<void> {
const { flags } = await this.parse(DisableMFACommand);
const { flags } = this;
if (!flags.email) {
this.logger.info('An email with --email must be provided');

View File

@@ -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<z.infer<typeof flagsSchema>> {
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({

View File

@@ -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<z.infer<typeof flagsSchema>> {
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!');

View File

@@ -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<void> {
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<void> {
this.logger.error('Error resetting database. See log messages for details.');
this.logger.error(error.message);
this.exit(1);
process.exit(1);
}
}

View File

@@ -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;

View File

@@ -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<z.infer<typeof flagsSchema>> {
/**
* 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');

View File

@@ -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/',
);
}
}

View File

@@ -0,0 +1,16 @@
import { z } from 'zod';
// Monkey-patch zod to support aliases
declare module 'zod' {
interface ZodType {
alias<T extends ZodType>(this: T, aliasName: string): T;
}
interface ZodTypeDef {
_alias: string;
}
}
z.ZodType.prototype.alias = function <T extends z.ZodType>(this: T, aliasName: string) {
this._def._alias = aliasName;
return this;
};

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 = <T extends BaseCommand>(Command: Class<T>) => {
const config = mock<Config>();
config.runHook.mockResolvedValue({ successes: [], failures: [] });
export const setupTestCommand = <T extends CommandClass>(Command: T) => {
// mock SIGINT/SIGTERM registration
process.once = jest.fn();
process.exit = jest.fn() as never;
@@ -34,8 +29,9 @@ export const setupTestCommand = <T extends BaseCommand>(Command: Class<T>) => {
});
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;
};

View File

@@ -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);
});

View File

@@ -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;

9
pnpm-lock.yaml generated
View File

@@ -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