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

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

View File

@@ -0,0 +1,116 @@
import { Container } from '@n8n/di';
import { z } from 'zod';
import { Command } from '../command';
import { CommandMetadata } from '../command-metadata';
describe('@Command decorator', () => {
let commandMetadata: CommandMetadata;
beforeEach(() => {
jest.resetAllMocks();
Container.reset();
commandMetadata = new CommandMetadata();
Container.set(CommandMetadata, commandMetadata);
});
it('should register command in CommandMetadata', () => {
@Command({
name: 'test',
description: 'Test command',
examples: ['example usage'],
flagsSchema: z.object({}),
})
class TestCommand {
async run() {}
}
const registeredCommands = commandMetadata.getEntries();
expect(registeredCommands).toHaveLength(1);
const [commandName, entry] = registeredCommands[0];
expect(commandName).toBe('test');
expect(entry.class).toBe(TestCommand);
});
it('should register multiple commands', () => {
@Command({
name: 'first-command',
description: 'First test command',
examples: ['example 1'],
flagsSchema: z.object({}),
})
class FirstCommand {
async run() {}
}
@Command({
name: 'second-command',
description: 'Second test command',
examples: ['example 2'],
flagsSchema: z.object({}),
})
class SecondCommand {
async run() {}
}
@Command({
name: 'third-command',
description: 'Third test command',
examples: ['example 3'],
flagsSchema: z.object({}),
})
class ThirdCommand {
async run() {}
}
const registeredCommands = commandMetadata.getEntries();
expect(registeredCommands).toHaveLength(3);
expect(commandMetadata.get('first-command')?.class).toBe(FirstCommand);
expect(commandMetadata.get('second-command')?.class).toBe(SecondCommand);
expect(commandMetadata.get('third-command')?.class).toBe(ThirdCommand);
});
it('should apply Service decorator', () => {
@Command({
name: 'test',
description: 'Test command',
examples: ['example usage'],
flagsSchema: z.object({}),
})
class TestCommand {
async run() {}
}
expect(Container.has(TestCommand)).toBe(true);
});
it('stores the command metadata correctly', () => {
const name = 'test-cmd';
const description = 'Test command description';
const examples = ['example 1', 'example 2'];
const flagsSchema = z.object({
flag1: z.string(),
flag2: z.boolean().optional(),
});
@Command({
name,
description,
examples,
flagsSchema,
})
class TestCommand {
async run() {}
}
const entry = commandMetadata.get(name);
expect(entry).toBeDefined();
expect(entry?.description).toBe(description);
expect(entry?.examples).toEqual(examples);
expect(entry?.flagsSchema).toBe(flagsSchema);
expect(entry?.class).toBe(TestCommand);
});
});

View File

@@ -0,0 +1,20 @@
import { Service } from '@n8n/di';
import type { CommandEntry } from './types';
@Service()
export class CommandMetadata {
private readonly commands: Map<string, CommandEntry> = new Map();
register(name: string, entry: CommandEntry) {
this.commands.set(name, entry);
}
get(name: string) {
return this.commands.get(name);
}
getEntries() {
return [...this.commands.entries()];
}
}

View File

@@ -0,0 +1,18 @@
import { Container, Service } from '@n8n/di';
import { CommandMetadata } from './command-metadata';
import type { CommandClass, CommandOptions } from './types';
export const Command =
({ name, description, examples, flagsSchema }: CommandOptions): ClassDecorator =>
(target) => {
const commandClass = target as unknown as CommandClass;
Container.get(CommandMetadata).register(name, {
description,
flagsSchema,
class: commandClass,
examples,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Service()(target);
};

View File

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

View File

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

View File

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