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(
];
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`", "Type of the credential.\nGet this value by visiting the node's .credential.ts file and getting the value of `name`",
}), )
userId: Flags.string({ .optional(),
description: 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', '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(),
});
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',
'$ 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.', 'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.',
}), )
id: Flags.string({ .optional(),
description: 'The ID of the credential to export', id: z.string().describe('The ID of the credential to export').optional(),
}), output: z
output: Flags.string({ .string()
char: 'o', .alias('o')
description: 'Output file name or directory if using separate files', .describe('Output file name or directory if using separate files')
}), .optional(),
pretty: Flags.boolean({ pretty: z.boolean().describe('Format the output in an easier to read fashion').optional(),
description: 'Format the output in an easier to read fashion', separate: z
}), .boolean()
separate: Flags.boolean({ .describe(
description:
'Exports one file per credential (useful for versioning). Must inform a directory via --output.', 'Exports one file per credential (useful for versioning). Must inform a directory via --output.',
}), )
decrypted: Flags.boolean({ .optional(),
description: 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).', '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 // 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',
'$ 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.', 'Sets --all --pretty --separate for simple backups. Only --output has to be set additionally.',
}), )
id: Flags.string({ .optional(),
description: 'The ID of the workflow to export', id: z.string().describe('The ID of the workflow to export').optional(),
}), output: z
output: Flags.string({ .string()
char: 'o', .alias('o')
description: 'Output file name or directory if using separate files', .describe('Output file name or directory if using separate files')
}), .optional(),
pretty: Flags.boolean({ pretty: z.boolean().describe('Format the output in an easier to read fashion').optional(),
description: 'Format the output in an easier to read fashion', separate: z
}), .boolean()
separate: Flags.boolean({ .describe(
description:
'Exports one file per workflow (useful for versioning). Must inform a directory via --output.', '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 // 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 = [
'$ 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', 'The ID of the user to assign the workflows and credentials owned by the deleted LDAP users to',
}), )
projectId: Flags.string({ .optional(),
description: projectId: z
.string()
.describe(
'The ID of the project to assign the workflows and credentials owned by the deleted LDAP users to', 'The ID of the project to assign the workflows and credentials owned by the deleted LDAP users to',
}), )
deleteWorkflowsAndCredentials: Flags.boolean({ .optional(),
description: deleteWorkflowsAndCredentials: z
.boolean()
.describe(
'Delete all workflows and credentials owned by the users that were created by the users managed via LDAP.', '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> { 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',
'$ 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!', 'runs the webhooks via a hooks.n8n.cloud tunnel server. Use only for testing and development!',
}), )
reinstallMissingPackages: Flags.boolean({ .optional(),
description: reinstallMissingPackages: z
.boolean()
.describe(
'Attempts to self heal n8n if packages with nodes are missing. Might drastically increase startup times.', '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 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