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 './controller';
export * from './command';
export { Debounce } from './debounce'; export { Debounce } from './debounce';
export * from './execution-lifecycle'; export * from './execution-lifecycle';
export { Memoized } from './memoized'; export { Memoized } from './memoized';

View File

@@ -13,11 +13,6 @@ if (versionFlags.includes(process.argv.slice(-1)[0])) {
process.exit(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 satisfies = require('semver/functions/satisfies');
const nodeVersion = process.versions.node; const nodeVersion = process.versions.node;
const { const {
@@ -63,10 +58,7 @@ if (process.env.NODEJS_PREFER_IPV4 === 'true') {
require('net').setDefaultAutoSelectFamily?.(false); require('net').setDefaultAutoSelectFamily?.(false);
(async () => { (async () => {
// Collect DB entities from modules _before_ `DbConnectionOptions` is instantiated. const { Container } = await import('@n8n/di');
const { BaseCommand } = await import('../dist/commands/base-command.js'); const { CommandRegistry } = await import('../dist/command-registry.js');
await new BaseCommand([], { root: __dirname }).loadModules(); await Container.get(CommandRegistry).execute();
const oclif = await import('@oclif/core');
await oclif.execute({ dir: __dirname });
})(); })();

View File

@@ -4,11 +4,6 @@
"description": "n8n Workflow Automation Tool", "description": "n8n Workflow Automation Tool",
"main": "dist/index", "main": "dist/index",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"oclif": {
"commands": "./dist/commands",
"helpClass": "./dist/help",
"bin": "n8n"
},
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -77,6 +72,7 @@
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@types/xml2js": "catalog:", "@types/xml2js": "catalog:",
"@types/yamljs": "^0.2.31", "@types/yamljs": "^0.2.31",
"@types/yargs-parser": "21.0.0",
"@vvo/tzdb": "^6.141.0", "@vvo/tzdb": "^6.141.0",
"concurrently": "^8.2.0", "concurrently": "^8.2.0",
"ioredis-mock": "^8.8.1", "ioredis-mock": "^8.8.1",
@@ -106,7 +102,6 @@
"@n8n/typeorm": "catalog:", "@n8n/typeorm": "catalog:",
"@n8n_io/ai-assistant-sdk": "catalog:", "@n8n_io/ai-assistant-sdk": "catalog:",
"@n8n_io/license-sdk": "2.22.0", "@n8n_io/license-sdk": "2.22.0",
"@oclif/core": "4.0.7",
"@rudderstack/rudder-sdk-node": "2.1.4", "@rudderstack/rudder-sdk-node": "2.1.4",
"@sentry/node": "catalog:", "@sentry/node": "catalog:",
"aws4": "1.11.0", "aws4": "1.11.0",
@@ -180,6 +175,7 @@
"xmllint-wasm": "3.0.1", "xmllint-wasm": "3.0.1",
"xss": "catalog:", "xss": "catalog:",
"yamljs": "0.3.0", "yamljs": "0.3.0",
"yargs-parser": "21.1.1",
"zod": "catalog:" "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 InstalledNodes } from '@n8n/db';
import { type CredentialsEntity } from '@n8n/db'; import { type CredentialsEntity } from '@n8n/db';
import { type User } from '@n8n/db'; import { type User } from '@n8n/db';
import { type Config } from '@oclif/core';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { CommunityNode } from '../community-node'; import { CommunityNode } from '../community-node';
@@ -9,8 +8,7 @@ import { CommunityNode } from '../community-node';
describe('uninstallCredential', () => { describe('uninstallCredential', () => {
const userId = '1234'; const userId = '1234';
const config: Config = mock<Config>(); const communityNode = new CommunityNode();
const communityNode = new CommunityNode(['--uninstall', '--credential', 'evolutionApi'], config);
beforeEach(() => { beforeEach(() => {
communityNode.deleteCredential = jest.fn(); communityNode.deleteCredential = jest.fn();
@@ -31,9 +29,8 @@ describe('uninstallCredential', () => {
const user = mock<User>(); const user = mock<User>();
const credentials = [credential]; const credentials = [credential];
communityNode.parseFlags = jest.fn().mockReturnValue({ // @ts-expect-error Protected property
flags: { credential: credentialType, uninstall: true, userId }, communityNode.flags = { credential: credentialType, uninstall: true, userId };
});
communityNode.findCredentialsByType = jest.fn().mockReturnValue(credentials); communityNode.findCredentialsByType = jest.fn().mockReturnValue(credentials);
communityNode.findUserById = jest.fn().mockReturnValue(user); communityNode.findUserById = jest.fn().mockReturnValue(user);
@@ -59,9 +56,8 @@ describe('uninstallCredential', () => {
const credential = mock<CredentialsEntity>(); const credential = mock<CredentialsEntity>();
credential.id = '666'; credential.id = '666';
communityNode.parseFlags = jest.fn().mockReturnValue({ // @ts-expect-error Protected property
flags: { credential: credentialType, uninstall: true, userId }, communityNode.flags = { credential: credentialType, uninstall: true, userId };
});
communityNode.findUserById = jest.fn().mockReturnValue(null); communityNode.findUserById = jest.fn().mockReturnValue(null);
const deleteCredential = jest.spyOn(communityNode, 'deleteCredential'); const deleteCredential = jest.spyOn(communityNode, 'deleteCredential');
@@ -83,9 +79,8 @@ describe('uninstallCredential', () => {
const credential = mock<CredentialsEntity>(); const credential = mock<CredentialsEntity>();
credential.id = '666'; credential.id = '666';
communityNode.parseFlags = jest.fn().mockReturnValue({ // @ts-expect-error Protected property
flags: { credential: credentialType, uninstall: true, userId }, communityNode.flags = { credential: credentialType, uninstall: true, userId };
});
communityNode.findUserById = jest.fn().mockReturnValue(mock<User>()); communityNode.findUserById = jest.fn().mockReturnValue(mock<User>());
communityNode.findCredentialsByType = jest.fn().mockReturnValue(null); communityNode.findCredentialsByType = jest.fn().mockReturnValue(null);
@@ -116,9 +111,8 @@ describe('uninstallCredential', () => {
const user = mock<User>(); const user = mock<User>();
const credentials = [credential1, credential2]; const credentials = [credential1, credential2];
communityNode.parseFlags = jest.fn().mockReturnValue({ // @ts-expect-error Protected property
flags: { credential: credentialType, uninstall: true, userId }, communityNode.flags = { credential: credentialType, uninstall: true, userId };
});
communityNode.findCredentialsByType = jest.fn().mockReturnValue(credentials); communityNode.findCredentialsByType = jest.fn().mockReturnValue(credentials);
communityNode.findUserById = jest.fn().mockReturnValue(user); communityNode.findUserById = jest.fn().mockReturnValue(user);
@@ -141,11 +135,7 @@ describe('uninstallCredential', () => {
}); });
describe('uninstallPackage', () => { describe('uninstallPackage', () => {
const config: Config = mock<Config>(); const communityNode = new CommunityNode();
const communityNode = new CommunityNode(
['--uninstall', '--package', 'n8n-nodes-evolution-api.evolutionApi'],
config,
);
beforeEach(() => { beforeEach(() => {
communityNode.removeCommunityPackage = jest.fn(); communityNode.removeCommunityPackage = jest.fn();
@@ -164,9 +154,8 @@ describe('uninstallPackage', () => {
installedNodes: [installedNode], installedNodes: [installedNode],
}; };
communityNode.parseFlags = jest.fn().mockReturnValue({ // @ts-expect-error Protected property
flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, communityNode.flags = { package: 'n8n-nodes-evolution-api', uninstall: true };
});
communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage);
const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode');
@@ -196,9 +185,8 @@ describe('uninstallPackage', () => {
installedNodes: [installedNode0, installedNode1], installedNodes: [installedNode0, installedNode1],
}; };
communityNode.parseFlags = jest.fn().mockReturnValue({ // @ts-expect-error Protected property
flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, communityNode.flags = { package: 'n8n-nodes-evolution-api', uninstall: true };
});
communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage);
const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode');
@@ -222,9 +210,8 @@ describe('uninstallPackage', () => {
}); });
it('should return if a package is not found', async () => { it('should return if a package is not found', async () => {
communityNode.parseFlags = jest.fn().mockReturnValue({ // @ts-expect-error Protected property
flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, communityNode.flags = { package: 'n8n-nodes-evolution-api', uninstall: true };
});
communityNode.findCommunityPackage = jest.fn().mockReturnValue(null); communityNode.findCommunityPackage = jest.fn().mockReturnValue(null);
const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode');
@@ -246,9 +233,8 @@ describe('uninstallPackage', () => {
installedNodes: [], installedNodes: [],
}; };
communityNode.parseFlags = jest.fn().mockReturnValue({ // @ts-expect-error Protected property
flags: { package: 'n8n-nodes-evolution-api', uninstall: true }, communityNode.flags = { package: 'n8n-nodes-evolution-api', uninstall: true };
});
communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage); communityNode.findCommunityPackage = jest.fn().mockReturnValue(communityPackage);
const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode'); const deleteCommunityNode = jest.spyOn(communityNode, 'deleteCommunityNode');

View File

@@ -5,7 +5,6 @@ import { WorkflowRepository } from '@n8n/db';
import { DbConnection } from '@n8n/db'; import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { SelectQueryBuilder } from '@n8n/typeorm'; import type { SelectQueryBuilder } from '@n8n/typeorm';
import type { Config } from '@oclif/core';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { IRun } from 'n8n-workflow'; 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); const cmd = new ExecuteBatch();
// @ts-expect-error Private property // @ts-expect-error Protected property
cmd.parse = jest.fn().mockResolvedValue({ flags: {} }); cmd.flags = {};
// @ts-expect-error Private property // @ts-expect-error Private property
cmd.runTests = jest.fn().mockResolvedValue({ summary: { failedExecutions: [] } }); 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 { WorkflowRepository } from '@n8n/db';
import { DbConnection } from '@n8n/db'; import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type { Config } from '@oclif/core';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { IRun } from 'n8n-workflow'; 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); const cmd = new Execute();
// @ts-expect-error Private property // @ts-expect-error Protected property
cmd.parse = jest.fn().mockResolvedValue({ flags: { id: '123' } }); cmd.flags = { id: '123' };
// act // act

View File

@@ -1,38 +1,35 @@
import { SecurityConfig } from '@n8n/config'; import { SecurityConfig } from '@n8n/config';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core';
import { UserError } from 'n8n-workflow'; import { UserError } from 'n8n-workflow';
import z from 'zod';
import { RISK_CATEGORIES } from '@/security-audit/constants'; import { RISK_CATEGORIES } from '@/security-audit/constants';
import type { Risk } from '@/security-audit/types'; import type { Risk } from '@/security-audit/types';
import { BaseCommand } from './base-command'; import { BaseCommand } from './base-command';
export class SecurityAudit extends BaseCommand { const flagsSchema = z.object({
static description = 'Generate a security audit report for this n8n instance'; categories: z
.string()
static examples = [ .default(RISK_CATEGORIES.join(','))
'$ n8n audit', .describe('Comma-separated list of categories to include in the audit'),
'$ n8n audit --categories=database,credentials', 'days-abandoned-workflow': z
'$ n8n audit --days-abandoned-workflow=10', .number()
]; .int()
.default(Container.get(SecurityConfig).daysAbandonedWorkflow)
static flags = { .describe('Days for a workflow to be considered abandoned if not executed'),
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',
}),
};
@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() { async run() {
const { flags: auditFlags } = await this.parse(SecurityAudit); const { flags: auditFlags } = this;
const categories = const categories =
auditFlags.categories?.split(',').filter((c): c is Risk.Category => c !== '') ?? auditFlags.categories?.split(',').filter((c): c is Risk.Category => c !== '') ??
RISK_CATEGORIES; RISK_CATEGORIES;

View File

@@ -11,7 +11,6 @@ import { GlobalConfig } from '@n8n/config';
import { LICENSE_FEATURES } from '@n8n/constants'; import { LICENSE_FEATURES } from '@n8n/constants';
import { DbConnection } from '@n8n/db'; import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Command, Errors } from '@oclif/core';
import { import {
BinaryDataConfig, BinaryDataConfig,
BinaryDataService, BinaryDataService,
@@ -20,7 +19,7 @@ import {
DataDeduplicationService, DataDeduplicationService,
ErrorReporter, ErrorReporter,
} from 'n8n-core'; } 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 type { AbstractServer } from '@/abstract-server';
import config from '@/config'; import config from '@/config';
@@ -39,7 +38,9 @@ import { PostHogClient } from '@/posthog';
import { ShutdownService } from '@/shutdown/shutdown.service'; import { ShutdownService } from '@/shutdown/shutdown.service';
import { WorkflowHistoryManager } from '@/workflows/workflow-history.ee/workflow-history-manager.ee'; 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 logger = Container.get(Logger);
protected dbConnection: DbConnection; protected dbConnection: DbConnection;
@@ -76,10 +77,6 @@ export abstract class BaseCommand extends Command {
/** Whether to init task runner (if enabled). */ /** Whether to init task runner (if enabled). */
protected needsTaskRunner = false; protected needsTaskRunner = false;
protected async loadModules() {
await this.moduleRegistry.loadModules();
}
async init(): Promise<void> { async init(): Promise<void> {
this.dbConnection = Container.get(DbConnection); this.dbConnection = Container.get(DbConnection);
this.errorReporter = Container.get(ErrorReporter); this.errorReporter = Container.get(ErrorReporter);
@@ -175,6 +172,14 @@ export abstract class BaseCommand extends Command {
process.exit(1); process.exit(1);
} }
protected log(message: string) {
this.logger.info(message);
}
protected error(message: string) {
throw new UnexpectedError(message);
}
async initObjectStoreService() { async initObjectStoreService() {
const binaryDataConfig = Container.get(BinaryDataConfig); const binaryDataConfig = Container.get(BinaryDataConfig);
const isSelected = binaryDataConfig.mode === 's3'; const isSelected = binaryDataConfig.mode === 's3';
@@ -193,7 +198,7 @@ export abstract class BaseCommand extends Command {
this.logger.error( 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.', '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'); 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) { async finally(error: Error | undefined) {
if (error?.message) this.logger.error(error.message); 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) { if (this.dbConnection.connectionState.connected) {
await sleep(100); // give any in-flight query some time to finish await sleep(100); // give any in-flight query some time to finish
await this.dbConnection.close(); await this.dbConnection.close();
} }
const exitCode = error instanceof Errors.ExitError ? error.oclif.exit : error ? 1 : 0; process.exit();
this.exit(exitCode);
} }
protected onTerminationSignal(signal: string) { protected onTerminationSignal(signal: string) {

View File

@@ -1,45 +1,43 @@
import { type InstalledNodes, type InstalledPackages, type User } from '@n8n/db'; import { type InstalledNodes, type InstalledPackages, type User } from '@n8n/db';
import { CredentialsRepository, InstalledNodesRepository, UserRepository } from '@n8n/db'; import { CredentialsRepository, InstalledNodesRepository, UserRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core'; import { z } from 'zod';
import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsService } from '@/credentials/credentials.service';
import { CommunityPackagesService } from '@/services/community-packages.service'; import { CommunityPackagesService } from '@/services/community-packages.service';
import { BaseCommand } from './base-command'; import { BaseCommand } from './base-command';
export class CommunityNode extends BaseCommand { const flagsSchema = z.object({
static description = '\nUninstall a community node and its credentials'; uninstall: z.boolean().describe('Uninstalls the node').optional(),
package: z.string().describe('Package name of the community node.').optional(),
static examples = [ credential: z
'$ n8n community-node --uninstall --package n8n-nodes-evolution-api', .string()
'$ n8n community-node --uninstall --credential evolutionApi --userId 1234', .describe(
]; "Type of the credential.\nGet this value by visiting the node's .credential.ts file and getting the value of `name`",
)
static flags = { .optional(),
help: Flags.help({ char: 'h' }), userId: z
uninstall: Flags.boolean({ .string()
description: 'Uninstalls the node', .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',
package: Flags.string({ )
description: 'Package name of the community node.', .optional(),
}), });
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();
}
@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() { async run() {
const { flags } = await this.parseFlags(); const { flags } = this;
const packageName = flags.package; const packageName = flags.package;
const credentialType = flags.credential; const credentialType = flags.credential;
@@ -139,10 +137,6 @@ export class CommunityNode extends BaseCommand {
await Container.get(CommunityPackagesService).executeNpmCommand('npm prune'); await Container.get(CommunityPackagesService).executeNpmCommand('npm prune');
} }
async parseFlags() {
return await this.parse(CommunityNode);
}
async deleteCommunityNode(node: InstalledNodes) { async deleteCommunityNode(node: InstalledNodes) {
return await Container.get(InstalledNodesRepository).delete({ return await Container.get(InstalledNodesRepository).delete({
type: node.type, type: node.type,

View File

@@ -1,12 +1,12 @@
import { Logger } from '@n8n/backend-common'; import { Logger } from '@n8n/backend-common';
import type { Migration } from '@n8n/db'; import type { Migration } from '@n8n/db';
import { wrapMigration, DbConnectionOptions } from '@n8n/db'; import { wrapMigration, DbConnectionOptions } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { DataSourceOptions as ConnectionOptions } from '@n8n/typeorm'; import type { DataSourceOptions as ConnectionOptions } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { MigrationExecutor, DataSource as Connection } from '@n8n/typeorm'; 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. // 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 // 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(); await connection.destroy();
} }
export class DbRevertMigrationCommand extends Command { @Command({
static description = 'Revert last database migration'; name: 'db:revert',
description: 'Revert last database migration',
static examples = ['$ n8n db:revert']; })
export class DbRevertMigrationCommand {
static flags = {
help: Flags.help({ char: 'h' }),
};
protected logger = Container.get(Logger);
private connection: Connection; private connection: Connection;
async init() { constructor(private readonly logger: Logger) {}
await this.parse(DbRevertMigrationCommand);
}
async run() { async run() {
const connectionOptions: ConnectionOptions = { const connectionOptions: ConnectionOptions = {
@@ -104,6 +96,6 @@ export class DbRevertMigrationCommand extends Command {
protected async finally(error: Error | undefined) { protected async finally(error: Error | undefined) {
if (this.connection?.isInitialized) await this.connection.destroy(); 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 */ /* eslint-disable @typescript-eslint/no-loop-func */
import type { User } from '@n8n/db'; import type { User } from '@n8n/db';
import { WorkflowRepository } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core';
import fs from 'fs'; import fs from 'fs';
import { diff } from 'json-diff'; import { diff } from 'json-diff';
import pick from 'lodash/pick'; import pick from 'lodash/pick';
@@ -10,6 +10,7 @@ import type { IRun, ITaskData, IWorkflowBase, IWorkflowExecutionDataProcess } fr
import { jsonParse, UnexpectedError } from 'n8n-workflow'; import { jsonParse, UnexpectedError } from 'n8n-workflow';
import os from 'os'; import os from 'os';
import { sep } from 'path'; import { sep } from 'path';
import { z } from 'zod';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
@@ -35,9 +36,81 @@ interface ISkipList {
ticketReference: string; ticketReference: string;
} }
export class ExecuteBatch extends BaseCommand { const flagsSchema = z.object({
static description = '\nExecutes multiple workflows once'; 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 cancelled = false;
static workflowExecutionsProgress: IWorkflowExecutionProgress[][]; static workflowExecutionsProgress: IWorkflowExecutionProgress[][];
@@ -58,63 +131,6 @@ export class ExecuteBatch extends BaseCommand {
static instanceOwner: User; 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']; static aliases = ['executeBatch'];
override needsCommunityPackages = true; override needsCommunityPackages = true;
@@ -182,7 +198,7 @@ export class ExecuteBatch extends BaseCommand {
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
async run() { async run() {
const { flags } = await this.parse(ExecuteBatch); const { flags } = this;
ExecuteBatch.debug = flags.debug; ExecuteBatch.debug = flags.debug;
ExecuteBatch.concurrency = flags.concurrency || 1; ExecuteBatch.concurrency = flags.concurrency || 1;
@@ -345,7 +361,7 @@ export class ExecuteBatch extends BaseCommand {
await this.stopProcess(true); await this.stopProcess(true);
if (results.summary.failedExecutions > 0) { if (results.summary.failedExecutions > 0) {
this.exit(1); process.exit(1);
} }
} }

View File

@@ -1,8 +1,9 @@
import { WorkflowRepository } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core';
import type { IWorkflowBase, IWorkflowExecutionDataProcess } from 'n8n-workflow'; import type { IWorkflowBase, IWorkflowExecutionDataProcess } from 'n8n-workflow';
import { ExecutionBaseError, UnexpectedError, UserError } from 'n8n-workflow'; import { ExecutionBaseError, UnexpectedError, UserError } from 'n8n-workflow';
import { z } from 'zod';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import { OwnershipService } from '@/services/ownership.service'; import { OwnershipService } from '@/services/ownership.service';
@@ -12,21 +13,20 @@ import { WorkflowRunner } from '@/workflow-runner';
import { BaseCommand } from './base-command'; import { BaseCommand } from './base-command';
import config from '../config'; import config from '../config';
export class Execute extends BaseCommand { const flagsSchema = z.object({
static description = '\nExecutes a given workflow'; id: z.string().describe('id of the workflow to execute').optional(),
rawOutput: z.boolean().describe('Outputs only JSON data, with no other text').optional(),
static examples = ['$ n8n execute --id=5']; /**@deprecated */
file: z.string().describe('DEPRECATED: Please use --id instead').optional(),
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',
}),
};
@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 needsCommunityPackages = true;
override needsTaskRunner = true; override needsTaskRunner = true;
@@ -39,7 +39,7 @@ export class Execute extends BaseCommand {
} }
async run() { async run() {
const { flags } = await this.parse(Execute); const { flags } = this;
if (!flags.id) { if (!flags.id) {
this.logger.info('"--id" has to be set!'); this.logger.info('"--id" has to be set!');

View File

@@ -1,59 +1,62 @@
import type { ICredentialsDb } from '@n8n/db'; import type { ICredentialsDb } from '@n8n/db';
import { CredentialsRepository } from '@n8n/db'; import { CredentialsRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core';
import fs from 'fs'; import fs from 'fs';
import { Credentials } from 'n8n-core'; import { Credentials } from 'n8n-core';
import { UserError } from 'n8n-workflow'; import { UserError } from 'n8n-workflow';
import path from 'path'; import path from 'path';
import z from 'zod';
import type { ICredentialsDecryptedDb } from '@/interfaces'; import type { ICredentialsDecryptedDb } from '@/interfaces';
import { BaseCommand } from '../base-command'; import { BaseCommand } from '../base-command';
export class ExportCredentialsCommand extends BaseCommand { const flagsSchema = z.object({
static description = 'Export credentials'; all: z.boolean().describe('Export all credentials').optional(),
backup: z
static examples = [ .boolean()
'$ n8n export:credentials --all', .describe(
'$ n8n export:credentials --id=5 --output=file.json', 'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.',
'$ n8n export:credentials --all --output=backups/latest.json', )
'$ n8n export:credentials --backup --output=backups/latest/', .optional(),
'$ n8n export:credentials --all --decrypted --output=backups/decrypted.json', id: z.string().describe('The ID of the credential to export').optional(),
]; output: z
.string()
static flags = { .alias('o')
help: Flags.help({ char: 'h' }), .describe('Output file name or directory if using separate files')
all: Flags.boolean({ .optional(),
description: 'Export all credentials', pretty: z.boolean().describe('Format the output in an easier to read fashion').optional(),
}), separate: z
backup: Flags.boolean({ .boolean()
description: .describe(
'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.', 'Exports one file per credential (useful for versioning). Must inform a directory via --output.',
}), )
id: Flags.string({ .optional(),
description: 'The ID of the credential to export', decrypted: z
}), .boolean()
output: Flags.string({ .describe(
char: 'o', '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).',
description: 'Output file name or directory if using separate files', )
}), .optional(),
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).',
}),
};
@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 // eslint-disable-next-line complexity
async run() { async run() {
const { flags } = await this.parse(ExportCredentialsCommand); const { flags } = this;
if (flags.backup) { if (flags.backup) {
flags.all = true; flags.all = true;
@@ -133,7 +136,7 @@ export class ExportCredentialsCommand extends BaseCommand {
for (i = 0; i < credentials.length; i++) { for (i = 0; i < credentials.length; i++) {
fileContents = JSON.stringify(credentials[i], null, flags.pretty ? 2 : undefined); fileContents = JSON.stringify(credentials[i], null, flags.pretty ? 2 : undefined);
const filename = `${ 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 credentials[i].id
}.json`; }.json`;
fs.writeFileSync(filename, fileContents); fs.writeFileSync(filename, fileContents);

View File

@@ -1,50 +1,51 @@
import { WorkflowRepository } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core';
import fs from 'fs'; import fs from 'fs';
import { UserError } from 'n8n-workflow'; import { UserError } from 'n8n-workflow';
import path from 'path'; import path from 'path';
import { z } from 'zod';
import { BaseCommand } from '../base-command'; import { BaseCommand } from '../base-command';
export class ExportWorkflowsCommand extends BaseCommand { const flagsSchema = z.object({
static description = 'Export workflows'; all: z.boolean().describe('Export all workflows').optional(),
backup: z
static examples = [ .boolean()
'$ n8n export:workflow --all', .describe(
'$ n8n export:workflow --id=5 --output=file.json', 'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.',
'$ n8n export:workflow --all --output=backups/latest/', )
'$ n8n export:workflow --backup --output=backups/latest/', .optional(),
]; id: z.string().describe('The ID of the workflow to export').optional(),
output: z
static flags = { .string()
help: Flags.help({ char: 'h' }), .alias('o')
all: Flags.boolean({ .describe('Output file name or directory if using separate files')
description: 'Export all workflows', .optional(),
}), pretty: z.boolean().describe('Format the output in an easier to read fashion').optional(),
backup: Flags.boolean({ separate: z
description: .boolean()
'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.', .describe(
}), 'Exports one file per workflow (useful for versioning). Must inform a directory via --output.',
id: Flags.string({ )
description: 'The ID of the workflow to export', .optional(),
}), });
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.',
}),
};
@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 // eslint-disable-next-line complexity
async run() { async run() {
const { flags } = await this.parse(ExportWorkflowsCommand); const { flags } = this;
if (flags.backup) { if (flags.backup) {
flags.all = true; flags.all = true;
@@ -89,7 +90,7 @@ export class ExportWorkflowsCommand extends BaseCommand {
this.logger.error(e.message); this.logger.error(e.message);
this.logger.error(e.stack!); this.logger.error(e.stack!);
} }
this.exit(1); process.exit(1);
} }
} else if (flags.output) { } else if (flags.output) {
if (fs.existsSync(flags.output)) { if (fs.existsSync(flags.output)) {
@@ -115,7 +116,7 @@ export class ExportWorkflowsCommand extends BaseCommand {
for (i = 0; i < workflows.length; i++) { for (i = 0; i < workflows.length; i++) {
fileContents = JSON.stringify(workflows[i], null, flags.pretty ? 2 : undefined); fileContents = JSON.stringify(workflows[i], null, flags.pretty ? 2 : undefined);
const filename = `${ 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 workflows[i].id
}.json`; }.json`;
fs.writeFileSync(filename, fileContents); fs.writeFileSync(filename, fileContents);

View File

@@ -1,50 +1,56 @@
import { CredentialsEntity, Project, User, SharedCredentials, ProjectRepository } from '@n8n/db'; import { CredentialsEntity, Project, User, SharedCredentials, ProjectRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager } from '@n8n/typeorm'; import type { EntityManager } from '@n8n/typeorm';
import { Flags } from '@oclif/core';
import glob from 'fast-glob'; import glob from 'fast-glob';
import fs from 'fs'; import fs from 'fs';
import { Cipher } from 'n8n-core'; import { Cipher } from 'n8n-core';
import type { ICredentialsEncrypted } from 'n8n-workflow'; import type { ICredentialsEncrypted } from 'n8n-workflow';
import { jsonParse, UserError } from 'n8n-workflow'; import { jsonParse, UserError } from 'n8n-workflow';
import { z } from 'zod';
import { UM_FIX_INSTRUCTION } from '@/constants'; import { UM_FIX_INSTRUCTION } from '@/constants';
import { BaseCommand } from '../base-command'; import { BaseCommand } from '../base-command';
export class ImportCredentialsCommand extends BaseCommand { const flagsSchema = z.object({
static description = 'Import credentials'; input: z
.string()
static examples = [ .alias('i')
'$ n8n import:credentials --input=file.json', .describe('Input file name or directory if --separate is used')
'$ n8n import:credentials --separate --input=backups/latest/', .optional(),
'$ n8n import:credentials --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', separate: z
'$ n8n import:credentials --input=file.json --projectId=Ox8O54VQrmBrb4qL', .boolean()
'$ n8n import:credentials --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', .default(false)
]; .describe('Imports *.json files from directory provided by --input'),
userId: z
static flags = { .string()
help: Flags.help({ char: 'h' }), .describe('The ID of the user to assign the imported credentials to')
input: Flags.string({ .optional(),
char: 'i', projectId: z
description: 'Input file name or directory if --separate is used', .string()
}), .describe('The ID of the project to assign the imported credential to')
separate: Flags.boolean({ .optional(),
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',
}),
};
@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; private transactionManager: EntityManager;
async run(): Promise<void> { async run(): Promise<void> {
const { flags } = await this.parse(ImportCredentialsCommand); const { flags } = this;
if (!flags.input) { if (!flags.input) {
this.logger.info('An input file or directory with --input must be provided'); this.logger.info('An input file or directory with --input must be provided');

View File

@@ -6,12 +6,13 @@ import {
WorkflowRepository, WorkflowRepository,
UserRepository, UserRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core';
import glob from 'fast-glob'; import glob from 'fast-glob';
import fs from 'fs'; import fs from 'fs';
import type { IWorkflowBase, WorkflowId } from 'n8n-workflow'; import type { IWorkflowBase, WorkflowId } from 'n8n-workflow';
import { jsonParse, UserError } from 'n8n-workflow'; import { jsonParse, UserError } from 'n8n-workflow';
import { z } from 'zod';
import { UM_FIX_INSTRUCTION } from '@/constants'; import { UM_FIX_INSTRUCTION } from '@/constants';
import type { IWorkflowToImport } from '@/interfaces'; import type { IWorkflowToImport } from '@/interfaces';
@@ -33,36 +34,38 @@ function assertHasWorkflowsToImport(
} }
} }
export class ImportWorkflowsCommand extends BaseCommand { const flagsSchema = z.object({
static description = 'Import workflows'; input: z
.string()
static examples = [ .alias('i')
'$ n8n import:workflow --input=file.json', .describe('Input file name or directory if --separate is used')
'$ n8n import:workflow --separate --input=backups/latest/', .optional(),
'$ n8n import:workflow --input=file.json --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', separate: z
'$ n8n import:workflow --input=file.json --projectId=Ox8O54VQrmBrb4qL', .boolean()
'$ n8n import:workflow --separate --input=backups/latest/ --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', .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(),
static flags = { projectId: z
help: Flags.help({ char: 'h' }), .string()
input: Flags.string({ .describe('The ID of the project to assign the imported workflows to')
char: 'i', .optional(),
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',
}),
};
@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> { async run(): Promise<void> {
const { flags } = await this.parse(ImportWorkflowsCommand); const { flags } = this;
if (!flags.input) { if (!flags.input) {
this.logger.info('An input file or directory with --input must be provided'); this.logger.info('An input file or directory with --input must be provided');

View File

@@ -9,11 +9,12 @@ import {
SharedWorkflowRepository, SharedWorkflowRepository,
UserRepository, UserRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm'; import { In } from '@n8n/typeorm';
import { Flags } from '@oclif/core';
import { UserError } from 'n8n-workflow'; import { UserError } from 'n8n-workflow';
import { z } from 'zod';
import { UM_FIX_INSTRUCTION } from '@/constants'; import { UM_FIX_INSTRUCTION } from '@/constants';
import { CredentialsService } from '@/credentials/credentials.service'; import { CredentialsService } from '@/credentials/credentials.service';
@@ -24,34 +25,41 @@ import { BaseCommand } from '../base-command';
const wrongFlagsError = const wrongFlagsError =
'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.'; 'You must use exactly one of `--userId`, `--projectId` or `--deleteWorkflowsAndCredentials`.';
export class Reset extends BaseCommand { const flagsSchema = z.object({
static description = userId: z
'\nResets the database to the default ldap state.\n\nTHIS DELETES ALL LDAP MANAGED USERS.'; .string()
.describe(
static examples = [ 'The ID of the user to assign the workflows and credentials owned by the deleted LDAP users to',
'$ n8n ldap:reset --userId=1d64c3d2-85fe-4a83-a649-e446b07b3aae', )
'$ n8n ldap:reset --projectId=Ox8O54VQrmBrb4qL', .optional(),
'$ n8n ldap:reset --deleteWorkflowsAndCredentials', projectId: z
]; .string()
.describe(
static flags = { 'The ID of the project to assign the workflows and credentials owned by the deleted LDAP users to',
help: Flags.help({ char: 'h' }), )
userId: Flags.string({ .optional(),
description: deleteWorkflowsAndCredentials: z
'The ID of the user to assign the workflows and credentials owned by the deleted LDAP users to', .boolean()
}), .describe(
projectId: Flags.string({ 'Delete all workflows and credentials owned by the users that were created by the users managed via LDAP.',
description: )
'The ID of the project to assign the workflows and credentials owned by the deleted LDAP users to', .optional(),
}), });
deleteWorkflowsAndCredentials: Flags.boolean({
description:
'Delete all workflows and credentials owned by the users that were created by the users managed via LDAP.',
}),
};
@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> { async run(): Promise<void> {
const { flags } = await this.parse(Reset); const { flags } = this;
const numberOfOptions = const numberOfOptions =
Number(!!flags.userId) + Number(!!flags.userId) +
Number(!!flags.projectId) + Number(!!flags.projectId) +

View File

@@ -1,14 +1,15 @@
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { License } from '@/license'; import { License } from '@/license';
import { BaseCommand } from '../base-command'; import { BaseCommand } from '../base-command';
@Command({
name: 'license:clear',
description: 'Clear local license certificate',
})
export class ClearLicenseCommand extends BaseCommand { export class ClearLicenseCommand extends BaseCommand {
static description = 'Clear local license certificate';
static examples = ['$ n8n license:clear'];
async run() { async run() {
this.logger.info('Clearing license from database.'); this.logger.info('Clearing license from database.');

View File

@@ -1,14 +1,15 @@
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { License } from '@/license'; import { License } from '@/license';
import { BaseCommand } from '../base-command'; import { BaseCommand } from '../base-command';
@Command({
name: 'license:info',
description: 'Print license information',
})
export class LicenseInfoCommand extends BaseCommand { export class LicenseInfoCommand extends BaseCommand {
static description = 'Print license information';
static examples = ['$ n8n license:info'];
async run() { async run() {
const license = Container.get(License); const license = Container.get(License);
await license.init({ isCli: true }); await license.init({ isCli: true });

View File

@@ -1,30 +1,27 @@
import { WorkflowRepository } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core'; import { z } from 'zod';
import { BaseCommand } from '../base-command'; import { BaseCommand } from '../base-command';
export class ListWorkflowCommand extends BaseCommand { const flagsSchema = z.object({
static description = '\nList workflows'; active: z
.string()
static examples = [ .describe('Filters workflows by active status. Can be true or false')
'$ n8n list:workflow', .optional(),
'$ n8n list:workflow --active=true --onlyId', onlyId: z.boolean().describe('Outputs workflow IDs only, one per line.').default(false),
'$ 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.',
}),
};
@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() { async run() {
const { flags } = await this.parse(ListWorkflowCommand); const { flags } = this;
if (flags.active !== undefined && !['true', 'false'].includes(flags.active)) { if (flags.active !== undefined && !['true', 'false'].includes(flags.active)) {
this.error('The --active flag has to be passed using true or false'); 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 { UserRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core'; import { z } from 'zod';
import { BaseCommand } from '../base-command'; import { BaseCommand } from '../base-command';
export class DisableMFACommand extends BaseCommand { const flagsSchema = z.object({
static description = 'Disable MFA authentication for a user'; email: z.string().describe('The email of the user to disable the MFA authentication'),
});
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();
}
@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> { async run(): Promise<void> {
const { flags } = await this.parse(DisableMFACommand); const { flags } = this;
if (!flags.email) { if (!flags.email) {
this.logger.info('An email with --email must be provided'); this.logger.info('An email with --email must be provided');

View File

@@ -2,8 +2,8 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */
import { LICENSE_FEATURES } from '@n8n/constants'; import { LICENSE_FEATURES } from '@n8n/constants';
import { ExecutionRepository, SettingsRepository } from '@n8n/db'; import { ExecutionRepository, SettingsRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core';
import glob from 'fast-glob'; import glob from 'fast-glob';
import { createReadStream, createWriteStream, existsSync } from 'fs'; import { createReadStream, createWriteStream, existsSync } from 'fs';
import { mkdir } from 'fs/promises'; import { mkdir } from 'fs/promises';
@@ -11,6 +11,7 @@ import { jsonParse, randomString, type IWorkflowExecutionDataProcess } from 'n8n
import path from 'path'; import path from 'path';
import replaceStream from 'replacestream'; import replaceStream from 'replacestream';
import { pipeline } from 'stream/promises'; import { pipeline } from 'stream/promises';
import { z } from 'zod';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import { ActiveWorkflowManager } from '@/active-workflow-manager'; 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 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires
const open = require('open'); const open = require('open');
export class Start extends BaseCommand { const flagsSchema = z.object({
static description = 'Starts n8n. Makes Web-UI available and starts active workflows'; open: z.boolean().alias('o').describe('opens the UI automatically in browser').optional(),
tunnel: z
static examples = [ .boolean()
'$ n8n start', .describe(
'$ n8n start --tunnel', 'runs the webhooks via a hooks.n8n.cloud tunnel server. Use only for testing and development!',
'$ n8n start -o', )
'$ n8n start --tunnel -o', .optional(),
]; reinstallMissingPackages: z
.boolean()
static flags = { .describe(
help: Flags.help({ char: 'h' }), 'Attempts to self heal n8n if packages with nodes are missing. Might drastically increase startup times.',
open: Flags.boolean({ )
char: 'o', .optional(),
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.',
}),
};
@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 activeWorkflowManager: ActiveWorkflowManager;
protected server = Container.get(Server); protected server = Container.get(Server);
@@ -181,7 +179,7 @@ export class Start extends BaseCommand {
scopedLogger.debug(`Host ID: ${this.instanceSettings.hostId}`); scopedLogger.debug(`Host ID: ${this.instanceSettings.hostId}`);
} }
const { flags } = await this.parse(Start); const { flags } = this;
const { communityPackages } = this.globalConfig.nodes; const { communityPackages } = this.globalConfig.nodes;
// cli flag overrides the config env variable // cli flag overrides the config env variable
if (flags.reinstallMissingPackages) { if (flags.reinstallMissingPackages) {
@@ -272,7 +270,7 @@ export class Start extends BaseCommand {
} }
async run() { async run() {
const { flags } = await this.parse(Start); const { flags } = this;
// Load settings from database and set them to config. // Load settings from database and set them to config.
const databaseSettings = await Container.get(SettingsRepository).findBy({ const databaseSettings = await Container.get(SettingsRepository).findBy({

View File

@@ -1,32 +1,25 @@
import { WorkflowRepository } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags } from '@oclif/core'; import { z } from 'zod';
import { BaseCommand } from '../base-command'; import { BaseCommand } from '../base-command';
export class UpdateWorkflowCommand extends BaseCommand { const flagsSchema = z.object({
static description = 'Update workflows'; active: z.string().describe('Active state the workflow/s should be set to').optional(),
all: z.boolean().describe('Operate on all workflows').optional(),
static examples = [ id: z.string().describe('The ID of the workflow to operate on').optional(),
'$ 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',
}),
};
@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() { async run() {
const { flags } = await this.parse(UpdateWorkflowCommand); const { flags } = this;
if (!flags.all && !flags.id) { if (!flags.all && !flags.id) {
this.logger.error('Either option "--all" or "--id" have to be set!'); this.logger.error('Either option "--all" or "--id" have to be set!');

View File

@@ -8,6 +8,7 @@ import {
SharedWorkflowRepository, SharedWorkflowRepository,
UserRepository, UserRepository,
} from '@n8n/db'; } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { BaseCommand } from '../base-command'; import { BaseCommand } from '../base-command';
@@ -20,11 +21,11 @@ const defaultUserProps = {
role: 'global:owner', role: 'global:owner',
}; };
@Command({
name: 'user-management:reset',
description: 'Resets the database to the default user state',
})
export class Reset extends BaseCommand { 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> { async run(): Promise<void> {
const owner = await this.getInstanceOwner(); const owner = await this.getInstanceOwner();
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
@@ -76,6 +77,6 @@ export class Reset extends BaseCommand {
async catch(error: Error): Promise<void> { async catch(error: Error): Promise<void> {
this.logger.error('Error resetting database. See log messages for details.'); this.logger.error('Error resetting database. See log messages for details.');
this.logger.error(error.message); 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 { Container } from '@n8n/di';
import { Flags } from '@oclif/core';
import { ActiveExecutions } from '@/active-executions'; import { ActiveExecutions } from '@/active-executions';
import config from '@/config'; import config from '@/config';
@@ -10,15 +10,11 @@ import { WebhookServer } from '@/webhooks/webhook-server';
import { BaseCommand } from './base-command'; import { BaseCommand } from './base-command';
@Command({
name: 'webhook',
description: 'Starts n8n webhook process. Intercepts only production URLs.',
})
export class Webhook extends BaseCommand { 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); protected server = Container.get(WebhookServer);
override needsCommunityPackages = true; override needsCommunityPackages = true;

View File

@@ -1,6 +1,7 @@
import { inTest } from '@n8n/backend-common'; import { inTest } from '@n8n/backend-common';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { Flags, type Config } from '@oclif/core'; import { z } from 'zod';
import config from '@/config'; import config from '@/config';
import { N8N_VERSION } from '@/constants'; import { N8N_VERSION } from '@/constants';
@@ -15,28 +16,26 @@ import type { WorkerServerEndpointsConfig } from '@/scaling/worker-server';
import { BaseCommand } from './base-command'; import { BaseCommand } from './base-command';
export class Worker extends BaseCommand { const flagsSchema = z.object({
static description = '\nStarts a n8n worker'; concurrency: z.number().int().default(10).describe('How many jobs can run in parallel.'),
});
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.',
}),
};
@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. * How many jobs this worker may run concurrently.
* *
* Taken from env var `N8N_CONCURRENCY_PRODUCTION_LIMIT` if set to a value * Taken from env var `N8N_CONCURRENCY_PRODUCTION_LIMIT` if set to a value
* other than -1, else taken from `--concurrency` flag. * other than -1, else taken from `--concurrency` flag.
*/ */
concurrency: number; private concurrency: number;
scalingService: ScalingService; private scalingService: ScalingService;
override needsCommunityPackages = true; override needsCommunityPackages = true;
@@ -59,12 +58,12 @@ export class Worker extends BaseCommand {
await this.exitSuccessFully(); await this.exitSuccessFully();
} }
constructor(argv: string[], cmdConfig: Config) { constructor() {
if (config.getEnv('executions.mode') !== 'queue') { if (config.getEnv('executions.mode') !== 'queue') {
config.set('executions.mode', 'queue'); config.set('executions.mode', 'queue');
} }
super(argv, cmdConfig); super();
this.logger = this.logger.scoped('scaling'); this.logger = this.logger.scoped('scaling');
} }
@@ -133,7 +132,7 @@ export class Worker extends BaseCommand {
} }
async setConcurrency() { async setConcurrency() {
const { flags } = await this.parse(Worker); const { flags } = this;
const envConcurrency = config.getEnv('executions.concurrency.productionLimit'); 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 { testDb } from '@n8n/backend-test-utils';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import '@/zod-alias-support';
import { ImportCredentialsCommand } from '@/commands/import/credentials'; import { ImportCredentialsCommand } from '@/commands/import/credentials';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command'; 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 { getAllSharedWorkflows, getAllWorkflows } from '@n8n/backend-test-utils';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import '@/zod-alias-support';
import { ImportWorkflowsCommand } from '@/commands/import/workflow'; import { ImportWorkflowsCommand } from '@/commands/import/workflow';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command'; import { setupTestCommand } from '@test-integration/utils/test-command';

View File

@@ -1,19 +1,14 @@
import { testDb } from '@n8n/backend-test-utils'; import { testDb } from '@n8n/backend-test-utils';
import { mockInstance } from '@n8n/backend-test-utils'; import { mockInstance } from '@n8n/backend-test-utils';
import type { Config } from '@oclif/core'; import type { CommandClass } from '@n8n/decorators';
import { mock } from 'jest-mock-extended'; import argvParser from 'yargs-parser';
import type { Class } from 'n8n-core';
import type { BaseCommand } from '@/commands/base-command';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay'; import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
mockInstance(MessageEventBus); mockInstance(MessageEventBus);
export const setupTestCommand = <T extends BaseCommand>(Command: Class<T>) => { export const setupTestCommand = <T extends CommandClass>(Command: T) => {
const config = mock<Config>();
config.runHook.mockResolvedValue({ successes: [], failures: [] });
// mock SIGINT/SIGTERM registration // mock SIGINT/SIGTERM registration
process.once = jest.fn(); process.once = jest.fn();
process.exit = jest.fn() as never; 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 run = async (argv: string[] = []) => {
const command = new Command(argv, config); const command = new Command();
await command.init(); command.flags = argvParser(argv);
await command.init?.();
await command.run(); await command.run();
return command; 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 type { InstanceType } from '@n8n/constants';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import type { NodeOptions } from '@sentry/node'; import type { NodeOptions } from '@sentry/node';
@@ -81,6 +81,8 @@ export class ErrorReporter {
serverName, serverName,
releaseDate, releaseDate,
}: ErrorReporterInitOptions) { }: ErrorReporterInitOptions) {
if (inTest) return;
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
this.error(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.`, 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; const { encryptionKey, tunnelSubdomain } = settings;

9
pnpm-lock.yaml generated
View File

@@ -1274,9 +1274,6 @@ importers:
'@n8n_io/license-sdk': '@n8n_io/license-sdk':
specifier: 2.22.0 specifier: 2.22.0
version: 2.22.0 version: 2.22.0
'@oclif/core':
specifier: 4.0.7
version: 4.0.7
'@rudderstack/rudder-sdk-node': '@rudderstack/rudder-sdk-node':
specifier: 2.1.4 specifier: 2.1.4
version: 2.1.4(tslib@2.8.1) version: 2.1.4(tslib@2.8.1)
@@ -1496,6 +1493,9 @@ importers:
yamljs: yamljs:
specifier: 0.3.0 specifier: 0.3.0
version: 0.3.0 version: 0.3.0
yargs-parser:
specifier: 21.1.1
version: 21.1.1
zod: zod:
specifier: 3.25.67 specifier: 3.25.67
version: 3.25.67 version: 3.25.67
@@ -1575,6 +1575,9 @@ importers:
'@types/yamljs': '@types/yamljs':
specifier: ^0.2.31 specifier: ^0.2.31
version: 0.2.31 version: 0.2.31
'@types/yargs-parser':
specifier: 21.0.0
version: 21.0.0
'@vvo/tzdb': '@vvo/tzdb':
specifier: ^6.141.0 specifier: ^6.141.0
version: 6.141.0 version: 6.141.0