first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,134 @@
import { cancel, outro } from '@clack/prompts';
import fs from 'node:fs/promises';
import { CommandTester } from '../test-utils/command-tester';
import { mockSpawn } from '../test-utils/mock-child-process';
import { setupTestPackage } from '../test-utils/package-setup';
import { tmpdirTest } from '../test-utils/temp-fs';
describe('build command', () => {
beforeEach(() => {
vi.clearAllMocks();
});
tmpdirTest(
'successful build - compiles TypeScript and copies static files',
async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
await fs.mkdir(`${tmpdir}/src/icons`, { recursive: true });
await fs.mkdir(`${tmpdir}/src/assets`, { recursive: true });
await fs.mkdir(`${tmpdir}/src/__schema__`, { recursive: true });
await fs.writeFile(`${tmpdir}/src/icons/icon.png`, 'fake-png-content');
await fs.writeFile(`${tmpdir}/src/assets/logo.svg`, '<svg>fake-svg</svg>');
await fs.writeFile(`${tmpdir}/src/__schema__/node.json`, '{"fake": "schema"}');
mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 });
await CommandTester.run('build');
await expect(tmpdir).toHaveFileEqual('dist/src/icons/icon.png', 'fake-png-content');
await expect(tmpdir).toHaveFileEqual('dist/src/assets/logo.svg', '<svg>fake-svg</svg>');
await expect(tmpdir).toHaveFileEqual('dist/src/__schema__/node.json', '{"fake": "schema"}');
expect(tmpdir).toHaveFile('dist/src/icons');
expect(tmpdir).toHaveFile('dist/src/assets');
expect(tmpdir).toHaveFile('dist/src/__schema__');
expect(outro).toHaveBeenCalledWith('✓ Build successful');
},
);
tmpdirTest('TypeScript compilation failure - exits with error', async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
mockSpawn('pnpm', ['exec', '--', 'tsc'], {
exitCode: 1,
stderr: "error TS2304: Cannot find name 'unknown_var'.",
});
await expect(CommandTester.run('build')).rejects.toThrow('EEXIT: 1');
expect(cancel).toHaveBeenCalledWith('TypeScript build failed');
});
tmpdirTest('child process error - handles spawn errors', async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
mockSpawn('pnpm', ['exec', '--', 'tsc'], {
error: 'ENOENT: no such file or directory, spawn tsc',
});
await expect(CommandTester.run('build')).rejects.toThrow('EEXIT: 1');
expect(cancel).toHaveBeenCalledWith('TypeScript build failed');
});
tmpdirTest('invalid package - not an n8n node package', async ({ tmpdir }) => {
await fs.writeFile(
`${tmpdir}/package.json`,
JSON.stringify({
name: 'regular-package',
version: '1.0.0',
// No n8n field - this makes it an invalid n8n package
}),
);
await expect(CommandTester.run('build')).rejects.toThrow('EEXIT: 1');
expect(cancel).toHaveBeenCalledWith('n8n-node build can only be run in an n8n node package');
});
tmpdirTest('no static files - still completes successfully', async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 });
await CommandTester.run('build');
expect(outro).toHaveBeenCalledWith('✓ Build successful');
});
tmpdirTest('static files in nested directories - creates correct paths', async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
await fs.mkdir(`${tmpdir}/src/nodes/icons`, { recursive: true });
await fs.mkdir(`${tmpdir}/src/nodes/subdir/__schema__`, { recursive: true });
await fs.mkdir(`${tmpdir}/src/assets/images`, { recursive: true });
await fs.writeFile(`${tmpdir}/src/nodes/icons/node1.png`, 'fake-node1-png');
await fs.writeFile(`${tmpdir}/src/nodes/subdir/__schema__/schema.json`, '{"node": "schema"}');
await fs.writeFile(`${tmpdir}/src/assets/images/logo.svg`, '<svg>logo</svg>');
mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 });
await CommandTester.run('build');
await expect(tmpdir).toHaveFileEqual('dist/src/nodes/icons/node1.png', 'fake-node1-png');
await expect(tmpdir).toHaveFileEqual(
'dist/src/nodes/subdir/__schema__/schema.json',
'{"node": "schema"}',
);
await expect(tmpdir).toHaveFileEqual('dist/src/assets/images/logo.svg', '<svg>logo</svg>');
expect(tmpdir).toHaveFile('dist/src/nodes/icons');
expect(tmpdir).toHaveFile('dist/src/nodes/subdir/__schema__');
expect(tmpdir).toHaveFile('dist/src/assets/images');
expect(outro).toHaveBeenCalledWith('✓ Build successful');
});
tmpdirTest('rimraf clears existing dist directory', async ({ tmpdir }) => {
await setupTestPackage(tmpdir);
await fs.mkdir(`${tmpdir}/dist/old-dir`, { recursive: true });
await fs.writeFile(`${tmpdir}/dist/old-file.js`, 'old content');
expect(tmpdir).toHaveFile('dist/old-file.js');
expect(tmpdir).toHaveFile('dist/old-dir');
mockSpawn('pnpm', ['exec', '--', 'tsc'], { exitCode: 0 });
await CommandTester.run('build');
expect(tmpdir).toNotHaveFile('dist/old-file.js');
expect(tmpdir).toNotHaveFile('dist/old-dir');
expect(outro).toHaveBeenCalledWith('✓ Build successful');
});
});
@@ -0,0 +1,66 @@
import { cancel, intro, log, outro, spinner } from '@clack/prompts';
import { Command } from '@oclif/core';
import glob from 'fast-glob';
import { cp, mkdir } from 'node:fs/promises';
import path from 'node:path';
import { rimraf } from 'rimraf';
import { runCommand } from '../utils/child-process';
import { ensureN8nPackage, getCommandHeader } from '../utils/prompts';
export default class Build extends Command {
static override description = 'Compile the node in the current directory and copy assets';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {};
async run(): Promise<void> {
await this.parse(Build);
const commandName = 'n8n-node build';
intro(await getCommandHeader(commandName));
await ensureN8nPackage(commandName);
const buildSpinner = spinner();
buildSpinner.start('Building TypeScript files');
await rimraf('dist');
try {
await runTscBuild();
buildSpinner.stop('TypeScript build successful');
} catch (error) {
cancel('TypeScript build failed');
this.exit(1);
}
const copyStaticFilesSpinner = spinner();
copyStaticFilesSpinner.start('Copying static files');
await copyStaticFiles();
copyStaticFilesSpinner.stop('Copied static files');
outro('✓ Build successful');
}
}
async function runTscBuild(): Promise<void> {
return await runCommand('tsc', [], {
context: 'local',
printOutput: ({ stdout, stderr }) => {
log.error(stdout.concat(stderr).toString());
},
});
}
export async function copyStaticFiles() {
const staticFiles = glob.sync(['**/*.{png,svg}', '**/__schema__/**/*.json'], {
ignore: ['dist', 'node_modules'],
});
return await Promise.all(
staticFiles.map(async (filePath) => {
const destPath = path.join('dist', filePath);
await mkdir(path.dirname(destPath), { recursive: true });
return await cp(filePath, destPath, { recursive: true });
}),
);
}
@@ -0,0 +1,124 @@
import { CommandTester } from '../test-utils/command-tester';
import { MockPrompt } from '../test-utils/mock-prompts';
import { setupTestPackage } from '../test-utils/package-setup';
import { tmpdirTest } from '../test-utils/temp-fs';
describe('cloud-support command', () => {
beforeEach(() => {
MockPrompt.reset();
});
describe('enable', () => {
tmpdirTest('writes correct eslint config and updates package.json', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: "import { config } from '@n8n/node-cli/eslint'; export default config;",
});
await CommandTester.run('cloud-support enable');
await expect(tmpdir).toHaveFileEqual(
'eslint.config.mjs',
"import { config } from '@n8n/node-cli/eslint';\n\nexport default config;\n",
);
await expect(tmpdir).toHaveFileContaining('package.json', '"strict": true');
});
});
describe('status', () => {
tmpdirTest('shows enabled status when strict mode and default config', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig: true,
});
const result = await CommandTester.run('cloud-support');
expect(result).toHaveLoggedSuccess('ENABLED');
});
tmpdirTest('shows disabled status when not strict mode', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: false } },
eslintConfig: true,
});
const result = await CommandTester.run('cloud-support');
expect(result).toHaveLoggedWarning('DISABLED');
});
});
describe('disable', () => {
tmpdirTest('updates config when user confirms', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig: true,
});
MockPrompt.setup([
{
question: 'Are you sure you want to disable cloud support?',
answer: true,
},
]);
const result = await CommandTester.run('cloud-support disable');
await expect(tmpdir).toHaveFileEqual(
'eslint.config.mjs',
"import { configWithoutCloudSupport } from '@n8n/node-cli/eslint';\n\nexport default configWithoutCloudSupport;\n",
);
await expect(tmpdir).toHaveFileContaining('package.json', '"strict": false');
expect(result).toHaveLoggedSuccess(
'Updated eslint.config.mjs to use configWithoutCloudSupport',
);
expect(result).toHaveLoggedSuccess('Disabled strict mode in package.json');
});
tmpdirTest('does not update config when user cancels', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig: true,
});
MockPrompt.setup([
{
question: 'Are you sure you want to disable cloud support?',
answer: 'CANCEL',
},
]);
await expect(CommandTester.run('cloud-support disable')).rejects.toThrow('EEXIT: 0');
await expect(tmpdir).toHaveFileEqual(
'eslint.config.mjs',
"import { config } from '@n8n/node-cli/eslint';\n\nexport default config;\n",
);
await expect(tmpdir).toHaveFileContaining('package.json', '"strict": true');
});
tmpdirTest('does not update config when user declines', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig: true,
});
MockPrompt.setup([
{
question: 'Are you sure you want to disable cloud support?',
answer: false,
},
]);
await expect(CommandTester.run('cloud-support disable')).rejects.toThrow('EEXIT: 0');
await expect(tmpdir).toHaveFileEqual(
'eslint.config.mjs',
"import { config } from '@n8n/node-cli/eslint';\n\nexport default config;\n",
);
await expect(tmpdir).toHaveFileContaining('package.json', '"strict": true');
});
});
});
@@ -0,0 +1,168 @@
import { confirm, intro, log, outro } from '@clack/prompts';
import { Args, Command } from '@oclif/core';
import fs from 'node:fs/promises';
import path from 'node:path';
import picocolors from 'picocolors';
import { suggestCloudSupportCommand, suggestLintCommand } from '../utils/command-suggestions';
import { getPackageJson, updatePackageJson } from '../utils/package';
import { ensureN8nPackage, getCommandHeader, onCancel, withCancelHandler } from '../utils/prompts';
export default class CloudSupport extends Command {
static override description = 'Enable or disable cloud support for this node';
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> enable',
'<%= config.bin %> <%= command.id %> disable',
];
static override args = {
action: Args.string({
description: 'Action to perform (defaults to showing current status)',
required: false,
options: ['enable', 'disable'],
}),
};
async run(): Promise<void> {
const { args } = await this.parse(CloudSupport);
await ensureN8nPackage('cloud-support');
const workingDir = process.cwd();
if (args.action === 'enable') {
await this.enableCloudSupport(workingDir);
} else if (args.action === 'disable') {
await this.disableCloudSupport(workingDir);
} else {
await this.showCloudSupportStatus(workingDir);
}
}
private async enableCloudSupport(workingDir: string): Promise<void> {
intro(await getCommandHeader('n8n-node cloud-support enable'));
await this.updateEslintConfig(workingDir, true);
log.success(`Updated ${picocolors.cyan('eslint.config.mjs')} to use default config`);
await this.updateStrictMode(workingDir, true);
log.success(`Enabled strict mode in ${picocolors.cyan('package.json')}`);
const lintCommand = await suggestLintCommand();
outro(
`Cloud support enabled. Run "${lintCommand}" to check compliance - your node must pass linting to be eligible for n8n Cloud publishing.`,
);
}
private async disableCloudSupport(workingDir: string): Promise<void> {
intro(await getCommandHeader('n8n-node cloud-support disable'));
log.warning(`This will make your node ineligible for n8n Cloud verification!
The following changes will be made:
• Switch to ${picocolors.magenta('configWithoutCloudSupport')} in ${picocolors.cyan('eslint.config.mjs')}
• Disable strict mode in ${picocolors.cyan('package.json')}`);
const confirmed = await withCancelHandler(
confirm({
message: 'Are you sure you want to disable cloud support?',
initialValue: false,
}),
);
if (!confirmed) {
onCancel('Cloud support unchanged');
return;
}
// 1. Update eslint.config.mjs
await this.updateEslintConfig(workingDir, false);
log.success(
`Updated ${picocolors.cyan('eslint.config.mjs')} to use ${picocolors.magenta('configWithoutCloudSupport')}`,
);
// 2. Disable strict mode in package.json
await this.updateStrictMode(workingDir, false);
log.success(`Disabled strict mode in ${picocolors.cyan('package.json')}`);
outro(
"Cloud support disabled. Your node may pass linting but it won't pass verification for n8n Cloud.",
);
}
private async updateEslintConfig(workingDir: string, enableCloud: boolean): Promise<void> {
const eslintConfigPath = path.resolve(workingDir, 'eslint.config.mjs');
const newConfig = enableCloud
? `import { config } from '@n8n/node-cli/eslint';
export default config;
`
: `import { configWithoutCloudSupport } from '@n8n/node-cli/eslint';
export default configWithoutCloudSupport;
`;
await fs.writeFile(eslintConfigPath, newConfig, 'utf-8');
}
private async updateStrictMode(workingDir: string, enableStrict: boolean): Promise<void> {
await updatePackageJson(workingDir, (packageJson) => {
packageJson.n8n = packageJson.n8n ?? {};
packageJson.n8n.strict = enableStrict;
return packageJson;
});
}
private async showCloudSupportStatus(workingDir: string): Promise<void> {
intro(await getCommandHeader('n8n-node cloud-support'));
try {
const packageJson = await getPackageJson(workingDir);
const eslintConfigPath = path.resolve(workingDir, 'eslint.config.mjs');
// Check strict mode
const isStrictMode = packageJson?.n8n?.strict === true;
// Check eslint config
let isUsingDefaultConfig = false;
try {
const eslintConfig = await fs.readFile(eslintConfigPath, 'utf-8');
const normalizedConfig = eslintConfig.replace(/\s+/g, ' ').trim();
const expectedDefault =
"import { config } from '@n8n/node-cli/eslint'; export default config;";
isUsingDefaultConfig = normalizedConfig === expectedDefault;
} catch {
// eslint config doesn't exist or can't be read
}
const isCloudSupported = isStrictMode && isUsingDefaultConfig;
if (isCloudSupported) {
log.success(`✅ Cloud support is ${picocolors.green('ENABLED')}
• Strict mode: ${picocolors.green('enabled')}
• ESLint config: ${picocolors.green('using default config')}
• Status: ${picocolors.green('eligible')} for n8n Cloud verification ${picocolors.dim('(if lint passes)')}`);
} else {
log.warning(`⚠️ Cloud support is ${picocolors.yellow('DISABLED')}
• Strict mode: ${isStrictMode ? picocolors.green('enabled') : picocolors.red('disabled')}
• ESLint config: ${isUsingDefaultConfig ? picocolors.green('using default config') : picocolors.red('using custom config')}
• Status: ${picocolors.red('NOT eligible')} for n8n Cloud verification`);
}
const enableCommand = await suggestCloudSupportCommand('enable');
const disableCommand = await suggestCloudSupportCommand('disable');
const lintCommand = await suggestLintCommand();
log.info(`Available commands:
${enableCommand} - Enable cloud support
${disableCommand} - Disable cloud support
${lintCommand} - Check compliance for cloud publishing`);
outro('Use the commands above to change cloud support settings or check compliance');
} catch (error) {
log.error('Failed to read package.json or determine cloud support status');
outro('Make sure you are in the root directory of your node package');
}
}
}
@@ -0,0 +1,174 @@
import type { Config } from '@oclif/core';
import path from 'node:path';
import { mock } from 'vitest-mock-extended';
import Dev from './index';
import { runCommands } from './utils';
import { setupTestPackage } from '../../test-utils/package-setup';
import { tmpdirTest } from '../../test-utils/temp-fs';
import { createSymlink } from '../../utils/filesystem';
import { onCancel } from '../../utils/prompts';
vi.mock('./utils', async () => {
const actual = await vi.importActual('./utils');
return {
...actual,
runCommands: vi.fn(),
createSpinner: vi.fn(() => vi.fn(() => 'spinner')),
openUrl: vi.fn(),
sleep: vi.fn(),
createOpenN8nHandler: vi.fn(() => ({ key: 'o', handler: vi.fn() })),
buildHelpText: vi.fn(() => 'Press q to quit | o to open n8n'),
};
});
vi.mock('../../utils/prompts', () => ({
onCancel: vi.fn((_msg: string, code?: number) => {
throw new Error(`EEXIT: ${code ?? 0}`);
}),
printCommandHeader: vi.fn(),
getCommandHeader: vi.fn().mockResolvedValue('Command Header'),
}));
vi.mock('../../utils/filesystem', async () => {
const actual = await vi.importActual('../../utils/filesystem');
return {
...actual,
createSymlink: vi.fn(),
};
});
describe('dev command', () => {
const createMockConfig = (tmpdir: string): Config =>
mock<Config>({
root: tmpdir,
runHook: async () => await Promise.resolve({ successes: [], failures: [] }),
});
beforeEach(() => {
vi.clearAllMocks();
});
tmpdirTest(
'creates symlink and starts TypeScript watcher with external-n8n flag',
async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const command = new Dev(['--external-n8n'], createMockConfig(tmpdir));
await command.run();
expect(createSymlink).toHaveBeenCalled();
expect(runCommands).toHaveBeenCalled();
const calls = vi.mocked(runCommands).mock.calls[0]?.[0];
expect(calls?.commands).toHaveLength(1);
expect(calls?.commands[0]?.name).toBe('TypeScript Build (watching)');
},
);
tmpdirTest('starts both TypeScript watcher and n8n server by default', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const command = new Dev([], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(runCommands).mock.calls[0]?.[0];
expect(calls?.commands).toHaveLength(2);
expect(calls?.commands[0]?.name).toBe('TypeScript Build (watching)');
expect(calls?.commands[1]?.name).toBe('n8n Server');
});
tmpdirTest('creates symlink in default custom folder location', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const command = new Dev(['--external-n8n'], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(createSymlink).mock.calls[0];
expect(calls?.[0]).toContain(tmpdir.split('/').pop());
expect(calls?.[1]).toContain('.n8n-node-cli');
expect(calls?.[1]).toContain('node_modules/n8n-nodes-test');
});
tmpdirTest('creates symlink in custom folder when specified', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const customFolder = path.join(tmpdir, 'my-custom-folder');
const command = new Dev(['--custom-user-folder', customFolder], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(createSymlink).mock.calls[0];
expect(calls?.[0]).toContain(tmpdir.split('/').pop());
expect(calls?.[1]).toBe(
path.join(customFolder, '.n8n', 'custom', 'node_modules', 'n8n-nodes-test'),
);
});
tmpdirTest('validates node name before creating symlink', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'invalid-node-name' },
});
const command = new Dev(['--external-n8n'], createMockConfig(tmpdir));
await expect(command.run()).rejects.toThrow('EEXIT');
expect(onCancel).toHaveBeenCalled();
expect(createSymlink).not.toHaveBeenCalled();
expect(runCommands).not.toHaveBeenCalled();
});
tmpdirTest('passes correct environment to n8n server', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const customFolder = path.join(tmpdir, 'custom');
const command = new Dev(['--custom-user-folder', customFolder], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(runCommands).mock.calls[0]?.[0];
const n8nCommand = calls?.commands.find((c) => c.name === 'n8n Server');
expect(n8nCommand).toBeDefined();
expect(n8nCommand?.env).toMatchObject({
N8N_DEV_RELOAD: 'true',
N8N_USER_FOLDER: customFolder,
});
});
tmpdirTest('includes open n8n key handler when n8n is enabled', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const command = new Dev([], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(runCommands).mock.calls[0]?.[0];
expect(calls?.keyHandlers).toBeDefined();
expect(calls?.keyHandlers).toHaveLength(1);
expect(calls?.keyHandlers?.[0]?.key).toBe('o');
});
tmpdirTest('includes no key handlers with external-n8n flag', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { name: 'n8n-nodes-test' },
});
const command = new Dev(['--external-n8n'], createMockConfig(tmpdir));
await command.run();
const calls = vi.mocked(runCommands).mock.calls[0]?.[0];
expect(calls?.keyHandlers).toBeDefined();
expect(calls?.keyHandlers).toHaveLength(0);
});
});
@@ -0,0 +1,122 @@
import { Command, Flags } from '@oclif/core';
import os from 'node:os';
import path from 'node:path';
import picocolors from 'picocolors';
import { createSymlink, ensureFolder } from '../../utils/filesystem';
import { detectPackageManager } from '../../utils/package-manager';
import { getCommandHeader, onCancel } from '../../utils/prompts';
import { validateNodeName } from '../../utils/validation';
import { copyStaticFiles } from '../build';
import {
buildHelpText,
type CommandConfig,
createOpenN8nHandler,
createSpinner,
readPackageName,
runCommands,
} from './utils';
export default class Dev extends Command {
static override description = 'Run n8n with the node and rebuild on changes for live preview';
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --external-n8n',
'<%= config.bin %> <%= command.id %> --custom-user-folder /Users/test',
];
static override flags = {
'external-n8n': Flags.boolean({
default: false,
description:
'By default n8n-node dev will run n8n in a sub process. Enable this option if you would like to run n8n elsewhere. Make sure to set N8N_DEV_RELOAD to true in that case.',
}),
'custom-user-folder': Flags.directory({
default: path.join(os.homedir(), '.n8n-node-cli'),
description:
'Folder to use to store user-specific n8n data. By default it will use ~/.n8n-node-cli. The node CLI will install your node here.',
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Dev);
const packageManager = (await detectPackageManager()) ?? 'npm';
await copyStaticFiles();
const n8nUserFolder = flags['custom-user-folder'];
const customNodesFolder = path.join(n8nUserFolder, '.n8n', 'custom');
const nodeModulesFolder = path.join(customNodesFolder, 'node_modules');
await ensureFolder(nodeModulesFolder);
const packageName = await readPackageName();
const invalidNodeNameError = validateNodeName(packageName);
if (invalidNodeNameError) return onCancel(invalidNodeNameError);
const currentDir = process.cwd();
const symlinkPath = path.join(nodeModulesFolder, packageName);
try {
await createSymlink(currentDir, symlinkPath);
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown error creating symbolic link';
return onCancel(`Failed to create symbolic link: ${message}`);
}
let n8nReady = false;
const hasN8n = !flags['external-n8n'];
let spinnerMessage = 'Starting n8n...';
setTimeout(() => {
spinnerMessage = `Installing n8n... ${picocolors.dim('(this can take a while on first run)')}`;
}, 10_000);
const n8nSpinner = createSpinner(() => spinnerMessage);
const commandsList: CommandConfig[] = [
{
cmd: packageManager,
args: ['exec', '--', 'tsc', '--watch', '--pretty'],
name: 'TypeScript Build (watching)',
},
];
if (hasN8n) {
commandsList.push({
cmd: 'npx',
args: ['-y', '--color=always', '--prefer-online', 'n8n@latest'],
name: 'n8n Server',
cwd: n8nUserFolder,
env: {
...process.env,
N8N_DEV_RELOAD: 'true',
DB_SQLITE_POOL_SIZE: '10',
N8N_USER_FOLDER: n8nUserFolder,
},
onOutput: (line: string) => {
if (line.includes('Editor is now accessible')) {
n8nReady = true;
}
},
getPlaceholder: n8nSpinner,
});
}
const keyHandlers = [];
if (hasN8n) {
keyHandlers.push(createOpenN8nHandler());
}
const headerText = await getCommandHeader('n8n-node dev');
runCommands({
commands: commandsList,
keyHandlers,
helpText: () => buildHelpText(hasN8n, n8nReady),
headerText,
});
}
}
@@ -0,0 +1,119 @@
import { execSync } from 'node:child_process';
import { createSpinner, openUrl, sleep } from './utils';
vi.mock('node:child_process');
describe('dev utils', () => {
describe('sleep', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should resolve after specified milliseconds', async () => {
const promise = sleep(100);
let resolved = false;
void promise.then(() => {
resolved = true;
});
vi.advanceTimersByTime(99);
await Promise.resolve();
expect(resolved).toBe(false);
vi.advanceTimersByTime(1);
await vi.runAllTimersAsync();
expect(resolved).toBe(true);
await expect(promise).resolves.toBeUndefined();
});
});
describe('createSpinner', () => {
it('should return a function that cycles through spinner frames', () => {
const spinner = createSpinner('Loading');
const frame1 = spinner();
const frame2 = spinner();
expect(frame1).toContain('Loading');
expect(frame2).toContain('Loading');
expect(frame1).not.toBe(frame2);
});
it('should cycle back to the first frame after all frames', () => {
const spinner = createSpinner('Test');
const frames = [];
for (let i = 0; i < 11; i++) {
frames.push(spinner());
}
expect(frames[0]).toBe(frames[10]);
});
});
describe('openUrl', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should use "open" command on darwin platform', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin' });
openUrl('http://localhost:5678');
expect(execSync).toHaveBeenCalledWith('open "http://localhost:5678"');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should use "start" command on win32 platform with empty window title', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
openUrl('http://localhost:5678');
expect(execSync).toHaveBeenCalledWith('start "" "http://localhost:5678"');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should use "xdg-open" command on linux platform', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux' });
openUrl('http://localhost:5678');
expect(execSync).toHaveBeenCalledWith('xdg-open "http://localhost:5678"');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should escape double quotes in URL', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin' });
openUrl('http://localhost:5678?query="value"');
expect(execSync).toHaveBeenCalledWith('open "http://localhost:5678?query=\\"value\\""');
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should not throw if execSync fails', () => {
vi.mocked(execSync).mockImplementation(() => {
throw new Error('Command failed');
});
expect(() => openUrl('http://localhost:5678')).not.toThrow();
});
});
});
@@ -0,0 +1,578 @@
import { type ChildProcess, execSync, spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import picocolors from 'picocolors';
import { jsonParse } from '../../utils/json';
interface CommandOutput {
name: string;
lines: string[];
isRunning: boolean;
exitCode: number | null;
getPlaceholder?: () => string;
}
const ANSI = {
CLEAR_SCREEN: '\x1b[2J',
CURSOR_HOME: '\x1b[H',
ENTER_ALT_SCREEN: '\x1b[?1049h',
EXIT_ALT_SCREEN: '\x1b[?1049l',
HIDE_CURSOR: '\x1b[?25l',
SHOW_CURSOR: '\x1b[?25h',
};
const CONFIG = {
MIN_LINES_PER_PANEL: 3,
MAX_LINES_PER_PANEL: 50,
RENDER_INTERVAL_MS: 100,
SEPARATOR_WIDTH: 80,
GRACEFUL_SHUTDOWN_TIMEOUT: 5000,
KILL_TIMEOUT_MS: 1000,
PROCESS_KILL_DELAY_MS: 100,
EXIT_KILL_TIMEOUT_MS: 500,
};
function calculatePanelHeight(numPanels: number, headerLines: number): number {
const terminalRows = process.stdout.rows ?? 24;
const panelOverheadPerPanel = 2;
const blankLinesBetweenPanels = numPanels - 1;
const helpTextLines = 2;
const totalOverhead =
headerLines + numPanels * panelOverheadPerPanel + blankLinesBetweenPanels + helpTextLines;
const availableRows = Math.max(0, terminalRows - totalOverhead);
const linesPerPanel = Math.floor(availableRows / numPanels);
const minRequiredRows =
headerLines +
numPanels * (CONFIG.MIN_LINES_PER_PANEL + panelOverheadPerPanel) +
blankLinesBetweenPanels +
helpTextLines;
if (terminalRows < minRequiredRows) {
return Math.max(1, linesPerPanel);
}
return Math.max(CONFIG.MIN_LINES_PER_PANEL, Math.min(CONFIG.MAX_LINES_PER_PANEL, linesPerPanel));
}
/* eslint-disable no-control-regex */
function stripScreenControlCodes(str: string): string {
return str
.replace(/\x1b\[2J/g, '')
.replace(/\x1b\[H/g, '')
.replace(/\x1b\[(\d+)?J/g, '')
.replace(/\x1b\[(\d+)?K/g, '')
.replace(/\x1b\[(\d+)?[ABCDEFG]/g, '');
}
/* eslint-enable no-control-regex */
function getStatusDisplay(output: CommandOutput) {
if (output.isRunning) {
return { icon: '', colorFn: picocolors.green, text: 'running' };
}
const exitCode = output.exitCode ?? 1;
if (exitCode === 130) {
return { icon: '✗ ', colorFn: picocolors.red, text: 'canceled' };
}
const success = exitCode === 0;
return {
icon: success ? '✓ ' : '✗ ',
colorFn: success ? picocolors.green : picocolors.red,
text: `exit ${exitCode}`,
};
}
function getVisibleLength(str: string): number {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, '').length;
}
function truncateLine(line: string, maxWidth: number): string {
if (getVisibleLength(line) <= maxWidth) return line;
let result = '';
let visible = 0;
let inAnsi = false;
for (const char of line) {
if (char === '\x1b') inAnsi = true;
if (inAnsi) {
result += char;
if (char === 'm') inAnsi = false;
continue;
}
if (visible >= maxWidth - 1) {
result += picocolors.dim('…');
break;
}
result += char;
visible++;
}
return result;
}
function processStreamData(data: Buffer, outputLines: string[]): void {
const text = data.toString().replace(/\r\n/g, '\n');
const segments = text.split('\r');
for (let i = 0; i < segments.length; i++) {
if (i > 0 && outputLines.length > 0) {
outputLines.pop();
}
const lines = segments[i].split('\n');
for (let j = 0; j < lines.length; j++) {
const isLastLine = j === lines.length - 1;
if (lines[j] || !isLastLine) {
outputLines.push(lines[j]);
}
}
}
}
export async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
export function createSpinner(text: string | (() => string)): () => string {
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let index = 0;
return () => {
const frame = picocolors.cyan(frames[index]);
index = (index + 1) % frames.length;
const message = typeof text === 'function' ? text() : text;
return `${frame} ${message}`;
};
}
function getOpenCommand(url: string): string {
const escapedUrl = url.replace(/"/g, '\\"');
switch (process.platform) {
case 'darwin':
return `open "${escapedUrl}"`;
case 'win32':
return `start "" "${escapedUrl}"`;
default:
return `xdg-open "${escapedUrl}"`;
}
}
export function openUrl(url: string): void {
try {
execSync(getOpenCommand(url));
} catch {
// Ignore errors when opening URLs
}
}
export interface CommandConfig {
cmd: string;
args: string[];
name: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
onOutput?: (line: string) => void;
getPlaceholder?: () => string;
}
export interface KeyHandler {
key: string;
description?: string;
handler: (cleanup: () => void) => void;
}
export interface CommandsConfig {
commands: CommandConfig[];
keyHandlers?: KeyHandler[];
helpText?: () => string;
headerText?: string;
}
interface RenderState {
lastOutput: string;
}
function renderPanel(output: CommandOutput, terminalWidth: number, panelHeight: number): string {
const status = getStatusDisplay(output);
const maxWidth = terminalWidth - 4;
const header = `╭─ ${status.colorFn(status.icon)}${picocolors.bold(output.name)} ${status.colorFn(`(${status.text})`)}\n`;
const recentLines = output.lines.slice(-panelHeight);
let content = '';
if (recentLines.length === 0 && output.getPlaceholder && output.isRunning) {
content = `${output.getPlaceholder()}\n`;
for (let i = 1; i < panelHeight; i++) {
content += '│\n';
}
} else {
for (let i = 0; i < panelHeight; i++) {
const cleanedLine = stripScreenControlCodes(recentLines[i] ?? '');
content += cleanedLine ? `${truncateLine(cleanedLine, maxWidth)}\n` : '│\n';
}
}
return header + content + '╰─\n';
}
function renderUI(outputs: CommandOutput[], helpText?: string, headerText?: string): string {
const terminalWidth = process.stdout.columns ?? CONFIG.SEPARATOR_WIDTH;
let result = '';
if (headerText) {
result += `${headerText}\n\n`;
}
const headerLines = headerText ? headerText.split('\n').length + 1 : 0;
const panelHeight = calculatePanelHeight(outputs.length, headerLines);
outputs.forEach((output, index) => {
result += renderPanel(output, terminalWidth, panelHeight);
if (index < outputs.length - 1) {
result += '\n';
}
});
const allRunning = outputs.every((o) => o.isRunning);
if (allRunning && helpText) {
result += `\n${helpText}\n`;
}
return result;
}
function doRender(
state: RenderState,
outputs: CommandOutput[],
helpText?: string,
headerText?: string,
): void {
const newOutput = renderUI(outputs, helpText, headerText);
if (newOutput === state.lastOutput) return;
process.stdout.write(ANSI.CLEAR_SCREEN + ANSI.CURSOR_HOME + newOutput);
state.lastOutput = newOutput;
}
function setupKeyboardInput(
handleSignal: () => void,
cleanup: () => void,
keyHandlers?: KeyHandler[],
): void {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', (key: string) => {
if (key === '\u0003' || key === 'q') {
handleSignal();
return;
}
const handler = keyHandlers?.find((h) => h.key === key && h.key !== 'q');
if (handler) {
handler.handler(cleanup);
}
});
}
async function handleCommandCompletion(
commandOutputs: CommandOutput[],
cleanup: (graceful: boolean) => Promise<void>,
): Promise<void> {
const exitedCommand = commandOutputs.find((o) => !o.isRunning);
if (!exitedCommand) return;
await cleanup(true);
const exitCode = exitedCommand.exitCode ?? 1;
const message =
exitCode === 0
? picocolors.green('Command completed successfully.')
: picocolors.red(`Command "${exitedCommand.name}" exited with code ${exitCode}.`);
process.stdout.write(`\n${picocolors.bold(message)}\n`);
process.exit(exitCode);
}
function restoreTerminal(): void {
if (process.stdout.isTTY) {
process.stdout.write(ANSI.SHOW_CURSOR);
process.stdout.write(ANSI.EXIT_ALT_SCREEN);
}
}
function printAllCommandOutputs(outputs: CommandOutput[], headerText?: string): void {
if (headerText) {
process.stdout.write(`\n${headerText}\n\n`);
}
outputs.forEach((output, index) => {
process.stdout.write(`${picocolors.bold(output.name)}\n`);
for (const line of output.lines) {
const cleanedLine = stripScreenControlCodes(line);
if (cleanedLine.trim()) {
process.stdout.write(`${cleanedLine}\n`);
}
}
if (index < outputs.length - 1) {
process.stdout.write('\n');
}
});
process.stdout.write(
`\n${picocolors.dim('Shutting down gracefully... Press Ctrl+C again to force quit.')}\n`,
);
}
async function killProcess(proc: ChildProcess, graceful: boolean): Promise<void> {
if (!proc.pid || proc.exitCode !== null) return;
const pid = proc.pid;
return await new Promise<void>((resolve) => {
let timeoutId: NodeJS.Timeout | null = null;
if (graceful) {
timeoutId = setTimeout(() => {
try {
if (process.platform === 'win32') {
execSync(`taskkill /PID ${pid} /T /F`, { timeout: CONFIG.KILL_TIMEOUT_MS });
} else {
process.kill(-pid, 'SIGKILL');
}
} catch {
// Ignore errors during force kill
}
resolve();
}, CONFIG.GRACEFUL_SHUTDOWN_TIMEOUT);
}
proc.once('exit', () => {
if (timeoutId) clearTimeout(timeoutId);
resolve();
});
try {
if (process.platform === 'win32') {
execSync(`taskkill /PID ${pid} /T /F`, { timeout: CONFIG.KILL_TIMEOUT_MS });
} else {
process.kill(-pid, graceful ? 'SIGTERM' : 'SIGKILL');
}
} catch {
try {
proc.kill(graceful ? 'SIGTERM' : 'SIGKILL');
} catch {
if (timeoutId) clearTimeout(timeoutId);
resolve();
}
}
if (!graceful) {
if (timeoutId) clearTimeout(timeoutId);
setTimeout(resolve, CONFIG.PROCESS_KILL_DELAY_MS);
}
});
}
export function runCommands(config: CommandsConfig): void {
const commandOutputs: CommandOutput[] = [];
const childProcesses: ChildProcess[] = [];
let renderInterval: NodeJS.Timeout | null = null;
let isShuttingDown = false;
let cleanupPerformed = false;
const cleanup = async (graceful: boolean = true): Promise<void> => {
if (cleanupPerformed) return;
cleanupPerformed = true;
if (renderInterval) {
clearInterval(renderInterval);
renderInterval = null;
}
restoreTerminal();
if (graceful) {
printAllCommandOutputs(commandOutputs, config.headerText);
}
await Promise.all(childProcesses.map(async (proc) => await killProcess(proc, graceful)));
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
};
const handleSignal = (): void => {
if (!isShuttingDown) {
isShuttingDown = true;
commandOutputs.forEach((output) => {
if (output.isRunning) {
output.isRunning = false;
output.exitCode = 130;
}
});
void cleanup(true).then(() => {
process.exit(130);
});
return;
}
if (cleanupPerformed) {
process.stdout.write(picocolors.yellow('\nForce quitting...\n'));
process.exit(130);
} else {
void cleanup(false).then(() => {
process.exit(130);
});
}
};
process.on('SIGINT', handleSignal);
process.on('SIGTERM', handleSignal);
process.on('exit', () => {
if (!cleanupPerformed && childProcesses.length > 0) {
for (const proc of childProcesses) {
if (!proc.pid) continue;
try {
if (process.platform === 'win32') {
execSync(`taskkill /PID ${proc.pid} /T /F`, { timeout: CONFIG.EXIT_KILL_TIMEOUT_MS });
} else {
process.kill(-proc.pid, 'SIGKILL');
}
} catch {
// Ignore errors during exit cleanup
}
}
}
});
process.on('uncaughtException', (error) => {
console.error(picocolors.red('\nUncaught exception:'), error);
void cleanup(false).then(() => {
process.exit(1);
});
});
process.on('unhandledRejection', (reason) => {
console.error(picocolors.red('\nUnhandled rejection:'), reason);
void cleanup(false).then(() => {
process.exit(1);
});
});
const startRenderLoop = (): void => {
if (renderInterval !== null) return;
if (process.stdout.isTTY) {
process.stdout.write(ANSI.ENTER_ALT_SCREEN);
process.stdout.write(ANSI.HIDE_CURSOR);
}
const state: RenderState = {
lastOutput: '',
};
if (process.stdin.isTTY) {
setupKeyboardInput(handleSignal, cleanup, config.keyHandlers);
}
doRender(state, commandOutputs, config.helpText?.(), config.headerText);
renderInterval = setInterval(() => {
doRender(state, commandOutputs, config.helpText?.(), config.headerText);
void handleCommandCompletion(commandOutputs, cleanup);
}, CONFIG.RENDER_INTERVAL_MS);
};
config.commands.forEach((cmdConfig) => {
const output: CommandOutput = {
name: cmdConfig.name,
lines: [],
isRunning: true,
exitCode: null,
getPlaceholder: cmdConfig.getPlaceholder,
};
commandOutputs.push(output);
const commandString = `${cmdConfig.cmd} ${cmdConfig.args.join(' ')}`;
const child = spawn(commandString, {
shell: true,
cwd: cmdConfig.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
env: {
...process.env,
...cmdConfig.env,
FORCE_COLOR: '3',
COLORTERM: 'truecolor',
TERM: 'xterm-256color',
},
});
childProcesses.push(child);
const handleData = (data: Buffer) => {
processStreamData(data, output.lines);
if (cmdConfig.onOutput) {
const lines = data.toString().split('\n');
for (const line of lines) {
if (line.trim()) {
cmdConfig.onOutput(line);
}
}
}
};
child.stdout.on('data', handleData);
child.stderr.on('data', handleData);
child.on('close', (code) => {
output.isRunning = false;
output.exitCode = code;
});
});
if (commandOutputs.length > 0) {
startRenderLoop();
}
}
export async function readPackageName(): Promise<string> {
return await fs
.readFile('package.json', 'utf-8')
.then((packageJson) => jsonParse<{ name: string }>(packageJson)?.name ?? 'unknown');
}
export function createOpenN8nHandler(): KeyHandler {
return {
key: 'o',
handler: () => {
openUrl('http://localhost:5678');
},
};
}
export function buildHelpText(hasN8n: boolean, isN8nReady: boolean): string {
const quitText = `${picocolors.dim('Press')} q ${picocolors.dim('to quit')}`;
if (hasN8n && isN8nReady) {
return `${quitText} ${picocolors.dim('|')} o ${picocolors.dim('to open n8n')}`;
}
return quitText;
}
@@ -0,0 +1,200 @@
import { cancel } from '@clack/prompts';
import fs from 'node:fs/promises';
import { CommandTester } from '../test-utils/command-tester';
import { stripAnsiCodes } from '../test-utils/matchers';
import { mockSpawn } from '../test-utils/mock-child-process';
import { setupTestPackage } from '../test-utils/package-setup';
import { tmpdirTest } from '../test-utils/temp-fs';
describe('lint command', () => {
const mockProcessStdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
beforeEach(() => {
vi.clearAllMocks();
});
tmpdirTest('successful lint - runs eslint with correct arguments', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], { exitCode: 0 });
const result = await CommandTester.run('lint');
expect(result).toBeDefined();
});
tmpdirTest('successful lint with warnings - shows warnings in output', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
const eslintWarnings = `
/tmp/project/src/index.ts
10:5 warning Unused variable 'unusedVar' @typescript-eslint/no-unused-vars
15:3 warning Missing return type @typescript-eslint/explicit-function-return-type
✖ 2 problems (0 errors, 2 warnings)
`;
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], {
exitCode: 0,
stdout: eslintWarnings,
});
const result = await CommandTester.run('lint');
expect(result).toBeDefined();
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const allOutput = stripAnsiCodes(
stdoutCalls.map((call) => (Buffer.isBuffer(call) ? call.toString() : String(call))).join(''),
);
expect(allOutput).toContain('Unused variable');
});
tmpdirTest('lint with fix flag - passes --fix to eslint', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.', '--fix'], { exitCode: 0 });
const result = await CommandTester.run('lint --fix');
expect(result).toBeDefined();
});
tmpdirTest('eslint failure - exits with error code', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], {
exitCode: 1,
stderr: 'ESLint found 3 errors',
});
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
});
tmpdirTest('eslint spawn error - handles process errors', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], {
error: 'ENOENT: no such file or directory, spawn eslint',
});
await expect(CommandTester.run('lint')).rejects.toThrow();
});
tmpdirTest('invalid package - not an n8n node package', async ({ tmpdir }) => {
await fs.writeFile(
`${tmpdir}/package.json`,
JSON.stringify({
name: 'regular-package',
version: '1.0.0',
// No n8n field - this makes it an invalid n8n package
}),
);
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
expect(cancel).toHaveBeenCalledWith('lint can only be run in an n8n node package');
});
tmpdirTest('strict mode with default config - passes validation', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], { exitCode: 0 });
const result = await CommandTester.run('lint');
expect(result).toBeDefined();
});
tmpdirTest('cloud-only lint errors - suggests disabling cloud support', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], {
exitCode: 1,
stderr: 'Error: @n8n/community-nodes/no-restricted-globals rule failed',
});
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const hasCloudMessage = stdoutCalls.some(
(call) =>
typeof call === 'string' && call.includes('n8n Cloud compatibility issues detected'),
);
expect(hasCloudMessage).toBe(true);
});
tmpdirTest('regular lint errors - no cloud suggestion', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
eslintConfig: true,
});
mockSpawn('pnpm', ['exec', '--', 'eslint', '.'], {
exitCode: 1,
stderr: 'Error: Unexpected token',
});
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const hasCloudMessage = stdoutCalls.some(
(call) => typeof call === 'string' && call.includes('n8n Cloud compatibility'),
);
expect(hasCloudMessage).toBe(false);
});
tmpdirTest('strict mode with modified config - fails validation', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
eslintConfig:
"import { config } from '@n8n/node-cli/eslint';\n\n// Custom modification\nexport default config;\n",
});
await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, 'lockfileVersion: 5.4\n');
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const hasStrictModeError = stdoutCalls.some(
(call) => typeof call === 'string' && call.includes('Strict mode violation:'),
);
expect(hasStrictModeError).toBe(true);
});
tmpdirTest('strict mode with missing config - fails validation', async ({ tmpdir }) => {
await setupTestPackage(tmpdir, {
packageJson: { n8n: { strict: true } },
});
await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, 'lockfileVersion: 5.4\n');
// Don't create eslint.config.mjs file (it will be missing)
await expect(CommandTester.run('lint')).rejects.toThrow('EEXIT: 1');
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const stdout = stdoutCalls
.filter((call) => typeof call === 'string')
.map(stripAnsiCodes)
.join('\n');
expect(stdout).toContain('eslint.config.mjs not found');
});
});
+143
View File
@@ -0,0 +1,143 @@
import { intro } from '@clack/prompts';
import { Command, Flags } from '@oclif/core';
import fs from 'node:fs/promises';
import path from 'node:path';
import picocolors from 'picocolors';
import { ChildProcessError, runCommand } from '../utils/child-process';
import { suggestCloudSupportCommand } from '../utils/command-suggestions';
import { getPackageJson } from '../utils/package';
import { ensureN8nPackage, getCommandHeader } from '../utils/prompts';
import { isEnoentError } from '../utils/validation';
export default class Lint extends Command {
static override description =
'Lint the node in the current directory. Includes auto-fixing. In strict mode, verifies eslint config is unchanged from default.';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {
fix: Flags.boolean({ description: 'Automatically fix problems', default: false }),
};
async run(): Promise<void> {
const { flags } = await this.parse(Lint);
intro(await getCommandHeader('n8n-node lint'));
await ensureN8nPackage('lint');
await this.checkStrictMode();
const args = ['.'];
if (flags.fix) {
args.push('--fix');
}
let eslintOutput = '';
try {
await runCommand('eslint', args, {
context: 'local',
stdio: 'pipe',
env: { ...process.env, FORCE_COLOR: '1' },
alwaysPrintOutput: true,
printOutput: ({ stdout, stderr }) => {
eslintOutput = Buffer.concat([...stdout, ...stderr]).toString();
process.stdout.write(Buffer.concat(stdout));
process.stderr.write(Buffer.concat(stderr));
},
});
} catch (error: unknown) {
if (error instanceof ChildProcessError) {
// Check if error might be related to cloud-only rules
await this.handleLintErrors(eslintOutput);
if (error.signal) {
process.kill(process.pid, error.signal);
} else {
process.exit(error.code ?? 0);
}
}
throw error;
}
}
private async checkStrictMode(): Promise<void> {
try {
const workingDir = process.cwd();
const packageJson = await getPackageJson(workingDir);
if (!packageJson?.n8n?.strict) {
return;
}
await this.verifyEslintConfig(workingDir);
} catch (error) {
return;
}
}
private async verifyEslintConfig(workingDir: string): Promise<void> {
const eslintConfigPath = path.resolve(workingDir, 'eslint.config.mjs');
const templatePath = path.resolve(
__dirname,
'../template/templates/shared/default/eslint.config.mjs',
);
const expectedConfig = await fs.readFile(templatePath, 'utf-8');
try {
const currentConfig = await fs.readFile(eslintConfigPath, 'utf-8');
const normalizedCurrent = currentConfig.replace(/\s+/g, ' ').trim();
const normalizedExpected = expectedConfig.replace(/\s+/g, ' ').trim();
if (normalizedCurrent !== normalizedExpected) {
const enableCommand = await suggestCloudSupportCommand('enable');
this.log(`${picocolors.red('Strict mode violation:')} ${picocolors.cyan('eslint.config.mjs')} has been modified from the default configuration.
${picocolors.dim('Expected:')}
${picocolors.gray(expectedConfig)}
To restore default config: ${enableCommand}
To disable strict mode: set ${picocolors.yellow('"strict": false')} in ${picocolors.cyan('package.json')} under the ${picocolors.yellow('"n8n"')} section.`);
process.exit(1);
}
} catch (error: unknown) {
if (isEnoentError(error)) {
const enableCommand = await suggestCloudSupportCommand('enable');
this.log(
`${picocolors.red('Strict mode violation:')} ${picocolors.cyan('eslint.config.mjs')} not found. Expected default configuration.
To create default config: ${enableCommand}`,
);
process.exit(1);
}
throw error;
}
}
private async handleLintErrors(eslintOutput: string): Promise<void> {
if (this.containsCloudOnlyErrors(eslintOutput)) {
const disableCommand = await suggestCloudSupportCommand('disable');
this.log(`${picocolors.yellow('⚠️ n8n Cloud compatibility issues detected')}
These lint failures prevent verification to n8n Cloud.
To disable cloud compatibility checks:
${disableCommand}
${picocolors.dim(`Note: This will switch to ${picocolors.magenta('configWithoutCloudSupport')} and disable strict mode`)}`);
}
}
private containsCloudOnlyErrors(errorMessage: string): boolean {
const cloudOnlyRules = [
'@n8n/community-nodes/no-restricted-imports',
'@n8n/community-nodes/no-restricted-globals',
];
return cloudOnlyRules.some((rule) => errorMessage.includes(rule));
}
}
@@ -0,0 +1,548 @@
import fs from 'node:fs/promises';
import { CommandTester } from '../../test-utils/command-tester';
import { mockSpawn, mockExecSync } from '../../test-utils/mock-child-process';
import { MockPrompt } from '../../test-utils/mock-prompts';
import { tmpdirTest } from '../../test-utils/temp-fs';
vi.mock('../../utils/filesystem', async () => {
const actual = await vi.importActual('../../utils/filesystem');
return {
...actual,
delayAtLeast: vi.fn(async <T>(promise: Promise<T>) => await promise),
};
});
describe('new command', () => {
beforeEach(() => {
vi.clearAllMocks();
MockPrompt.reset();
});
tmpdirTest('creates new node project with user prompts', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'programmatic',
},
{
question: 'What type of programmatic node are you building?',
answer: 'basic',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Test User\n' },
{ command: 'git config --get user.email', result: 'test@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
{
command: 'pnpm',
args: ['install'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-my-awesome-api');
expect(MockPrompt).toHaveAskedAllQuestions();
expect(MockPrompt).toHaveAskedQuestion('What kind of node are you building?');
expect(tmpdir).toHaveFile('n8n-nodes-my-awesome-api');
await expect(tmpdir).toHaveFileContaining(
'n8n-nodes-my-awesome-api/package.json',
'"name": "n8n-nodes-my-awesome-api"',
);
await expect(tmpdir).toHaveFileContaining(
'n8n-nodes-my-awesome-api/package.json',
'"name": "Test User"',
);
await expect(tmpdir).toHaveFileContaining(
'n8n-nodes-my-awesome-api/package.json',
'"email": "test@example.com"',
);
await expect(tmpdir).toHaveFileContaining(
'n8n-nodes-my-awesome-api/nodes/Example/Example.node.ts',
'export class Example implements INodeType',
);
// Check if credentials files exist
try {
const credentialsPath = `${tmpdir}/n8n-nodes-my-awesome-api/credentials`;
const credentialFiles = await fs.readdir(credentialsPath);
if (credentialFiles.length > 0) {
await expect(tmpdir).toHaveFileContaining(
`n8n-nodes-my-awesome-api/credentials/${credentialFiles[0]}`,
'implements ICredentialType',
);
}
} catch {
// Credentials directory doesn't exist, which is fine
}
});
tmpdirTest('creates new node project with node name prompt', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: "Package name (must start with 'n8n-nodes-' or '@org/n8n-nodes-')",
answer: 'n8n-nodes-interactive-demo',
},
{
question: 'What kind of node are you building?',
answer: 'declarative',
},
{
question: 'What template do you want to use?',
answer: 'githubIssues',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Test User\n' },
{ command: 'git config --get user.email', result: 'test@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-interactive-demo';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"name": "n8n-nodes-interactive-demo"',
);
await expect(tmpdir).toHaveFileContaining(`${projectName}/package.json`, '"name": "Test User"');
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"email": "test@example.com"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/GithubIssues/GithubIssues.node.ts`,
'export class GithubIssues implements INodeType',
);
// Check if credentials files exist
try {
const credentialsPath = `${tmpdir}/${projectName}/credentials`;
const credentialFiles = await fs.readdir(credentialsPath);
if (credentialFiles.length > 0) {
await expect(tmpdir).toHaveFileContaining(
`${projectName}/credentials/${credentialFiles[0]}`,
'implements ICredentialType',
);
}
} catch {
// Credentials directory doesn't exist, which is fine
}
});
tmpdirTest(
'creates new node project with custom template',
async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'declarative',
},
{
question: 'What template do you want to use?',
answer: 'custom',
},
{
question: "What's the base URL of the API?",
answer: 'https://api.custom-service.com',
},
{
question: 'What type of authentication does your API use?',
answer: 'apiKey',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Custom User\n' },
{ command: 'git config --get user.email', result: 'custom@test.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-custom-api --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-custom-api';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"name": "n8n-nodes-custom-api"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"name": "Custom User"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"email": "custom@test.com"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/CustomApi/CustomApi.node.ts`,
'implements INodeType',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/credentials/CustomApiApi.credentials.ts`,
'implements ICredentialType',
);
},
15_000,
);
test('handles prompt cancellation gracefully', async () => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'CANCEL',
},
]);
await expect(CommandTester.run('new n8n-nodes-cancelled --skip-install')).rejects.toThrow(
'EEXIT: 0',
);
expect(MockPrompt).toHaveAskedAllQuestions();
});
tmpdirTest(
'creates new node project with all arguments provided (no prompts)',
async ({ tmpdir }) => {
MockPrompt.setup([]);
mockExecSync([
{ command: 'git config --get user.name', result: 'No Prompt User\n' },
{ command: 'git config --get user.email', result: 'noprompt@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run(
'new n8n-nodes-full-args --template declarative/github-issues --force --skip-install',
);
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-full-args';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"name": "n8n-nodes-full-args"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"name": "No Prompt User"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/package.json`,
'"email": "noprompt@example.com"',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/GithubIssues/GithubIssues.node.ts`,
'export class GithubIssues implements INodeType',
);
// Check if credentials files exist
try {
const credentialsPath = `${tmpdir}/${projectName}/credentials`;
const credentialFiles = await fs.readdir(credentialsPath);
if (credentialFiles.length > 0) {
await expect(tmpdir).toHaveFileContaining(
`${projectName}/credentials/${credentialFiles[0]}`,
'implements ICredentialType',
);
}
} catch {
// Credentials directory doesn't exist, which is fine
}
},
);
tmpdirTest('creates new node project with OpenAI compatible chat model', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'programmatic',
},
{
question: 'What type of programmatic node are you building?',
answer: 'chatModel',
},
{
question: 'What type of chat model?',
answer: 'openaiCompatible',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Chat User\n' },
{ command: 'git config --get user.email', result: 'chat@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
{
command: 'pnpm',
args: ['install'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-chat-openai --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
expect(MockPrompt).toHaveAskedQuestion('What type of chat model?');
const projectName = 'n8n-nodes-chat-openai';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatModel/ExampleChatModel.node.ts`,
'export class ExampleChatModel implements INodeType',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatModel/ExampleChatModel.node.ts`,
'Chat model node for OpenAI API compatible providers',
);
});
tmpdirTest('creates new node project with custom chat model', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'programmatic',
},
{
question: 'What type of programmatic node are you building?',
answer: 'chatModel',
},
{
question: 'What type of chat model?',
answer: 'custom',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Custom Chat User\n' },
{ command: 'git config --get user.email', result: 'customchat@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-custom-chat --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-custom-chat';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatModel/ExampleChatModel.node.ts`,
'export class ExampleChatModel implements INodeType',
);
});
tmpdirTest('creates new node project with custom chat model example', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'programmatic',
},
{
question: 'What type of programmatic node are you building?',
answer: 'chatModel',
},
{
question: 'What type of chat model?',
answer: 'customExample',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Example User\n' },
{ command: 'git config --get user.email', result: 'example@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-chat-example --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-chat-example';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatModel/ExampleChatModel.node.ts`,
'export class ExampleChatModel implements INodeType',
);
});
tmpdirTest('creates new node project with chat memory', async ({ tmpdir }) => {
MockPrompt.setup([
{
question: 'What kind of node are you building?',
answer: 'programmatic',
},
{
question: 'What type of programmatic node are you building?',
answer: 'chatMemory',
},
]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Memory User\n' },
{ command: 'git config --get user.email', result: 'memory@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run('new n8n-nodes-chat-memory --skip-install');
expect(MockPrompt).toHaveAskedAllQuestions();
expect(MockPrompt).toHaveAskedQuestion('What type of programmatic node are you building?');
const projectName = 'n8n-nodes-chat-memory';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatMemory/ExampleChatMemory.node.ts`,
'export class ExampleChatMemory implements INodeType',
);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatMemory/ExampleChatMemory.node.ts`,
'Store conversation history in memory',
);
});
tmpdirTest(
'creates new node project with --template programmatic/openai-chat-model',
async ({ tmpdir }) => {
MockPrompt.setup([]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Template User\n' },
{ command: 'git config --get user.email', result: 'template@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run(
'new n8n-nodes-template-chat --template programmatic/openai-chat-model --skip-install',
);
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-template-chat';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatModel/ExampleChatModel.node.ts`,
'export class ExampleChatModel implements INodeType',
);
},
);
tmpdirTest(
'creates new node project with --template programmatic/custom-chat-memory',
async ({ tmpdir }) => {
MockPrompt.setup([]);
mockExecSync([
{ command: 'git config --get user.name', result: 'Memory Template User\n' },
{ command: 'git config --get user.email', result: 'memory-tpl@example.com\n' },
]);
mockSpawn([
{
command: 'git',
args: ['init', '-b', 'main'],
options: { exitCode: 0 },
},
]);
await CommandTester.run(
'new n8n-nodes-template-memory --template programmatic/custom-chat-memory --skip-install',
);
expect(MockPrompt).toHaveAskedAllQuestions();
const projectName = 'n8n-nodes-template-memory';
expect(tmpdir).toHaveFile(projectName);
await expect(tmpdir).toHaveFileContaining(
`${projectName}/nodes/ExampleChatMemory/ExampleChatMemory.node.ts`,
'export class ExampleChatMemory implements INodeType',
);
},
);
});
@@ -0,0 +1,191 @@
import { confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts';
import { Args, Command, Flags } from '@oclif/core';
import { camelCase } from 'change-case';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
chatModelTypePrompt,
declarativeTemplatePrompt,
nodeNamePrompt,
nodeTypePrompt,
programmaticNodeTypePrompt,
} from './prompts';
import { createIntro } from './utils';
import type { TemplateData, TemplateWithRun } from '../../template/core';
import { getTemplate, isTemplateName, isTemplateType, templates } from '../../template/templates';
import { ChildProcessError, runCommand } from '../../utils/child-process';
import { delayAtLeast, folderExists } from '../../utils/filesystem';
import { initGit, tryReadGitUser } from '../../utils/git';
import { detectPackageManager } from '../../utils/package-manager';
import { onCancel } from '../../utils/prompts';
import { validateNodeName } from '../../utils/validation';
export default class New extends Command {
static override description = 'Create a starter community node in a new directory';
static override examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> n8n-nodes-my-app --skip-install',
'<%= config.bin %> <%= command.id %> n8n-nodes-my-app --force',
'<%= config.bin %> <%= command.id %> n8n-nodes-my-app --template declarative/custom',
];
static override args = {
name: Args.string({ name: 'Name' }),
};
static override flags = {
force: Flags.boolean({
char: 'f',
description: 'Overwrite destination folder if it already exists',
}),
'skip-install': Flags.boolean({ description: 'Skip installing dependencies' }),
template: Flags.string({
options: [
'declarative/github-issues',
'declarative/custom',
'programmatic/example',
'programmatic/openai-chat-model',
'programmatic/custom-chat-model',
'programmatic/custom-chat-model-example',
'programmatic/custom-chat-memory',
] as const,
}),
};
async run(): Promise<void> {
const { flags, args } = await this.parse(New);
const [typeFlag, templateFlag] = flags.template?.split('/') ?? [];
intro(await createIntro());
const nodeName = args.name ?? (await nodeNamePrompt());
const invalidNodeNameError = validateNodeName(nodeName);
if (invalidNodeNameError) return onCancel(invalidNodeNameError);
const destination = path.resolve(process.cwd(), nodeName);
let overwrite = false;
if (await folderExists(destination)) {
if (!flags.force) {
const shouldOverwrite = await confirm({
message: `./${nodeName} already exists, do you want to overwrite?`,
});
if (isCancel(shouldOverwrite) || !shouldOverwrite) return onCancel();
}
overwrite = true;
}
const type = typeFlag ?? (await nodeTypePrompt());
if (!isTemplateType(type)) {
return onCancel(`Invalid template type: ${type}`);
}
let template: TemplateWithRun = templates.programmatic.example;
if (templateFlag) {
const name = camelCase(templateFlag);
if (!isTemplateName(type, name)) {
return onCancel(`Invalid template name: ${name} for type: ${type}`);
}
template = getTemplate(type, name);
} else if (type === 'declarative') {
const chosenTemplate = await declarativeTemplatePrompt();
template = getTemplate('declarative', chosenTemplate) as TemplateWithRun;
} else if (type === 'programmatic') {
const programmaticNodeType = await programmaticNodeTypePrompt();
if (programmaticNodeType === 'basic') {
template = templates.programmatic.example;
} else if (programmaticNodeType === 'chatModel') {
const chatModelType = await chatModelTypePrompt();
if (chatModelType === 'openaiCompatible') {
template = templates.programmatic.openaiChatModel as TemplateWithRun;
} else if (chatModelType === 'custom') {
template = templates.programmatic.customChatModel as TemplateWithRun;
} else if (chatModelType === 'customExample') {
template = templates.programmatic.customChatModelExample as TemplateWithRun;
}
} else if (programmaticNodeType === 'chatMemory') {
template = templates.programmatic.customChatMemory as TemplateWithRun;
}
}
const config = (await template.prompts?.()) ?? {};
const packageManager = (await detectPackageManager()) ?? 'npm';
const templateData: TemplateData = {
destinationPath: destination,
nodePackageName: nodeName,
config,
user: tryReadGitUser(),
packageManager: {
name: packageManager,
installCommand: packageManager === 'npm' ? 'ci' : 'install',
},
};
const copyingSpinner = spinner();
copyingSpinner.start('Copying files');
if (overwrite) {
await fs.rm(destination, { recursive: true, force: true });
}
await delayAtLeast(template.run(templateData), 1000);
copyingSpinner.stop('Files copied');
const gitSpinner = spinner();
gitSpinner.start('Initializing git repository');
try {
await initGit(destination);
gitSpinner.stop('Git repository initialized');
} catch (error: unknown) {
if (error instanceof ChildProcessError) {
gitSpinner.stop(
`Could not initialize git repository: ${error.message}`,
error.code ?? undefined,
);
process.exit(error.code ?? 1);
} else {
throw error;
}
}
if (!flags['skip-install']) {
const installingSpinner = spinner();
installingSpinner.start('Installing dependencies');
try {
await delayAtLeast(
runCommand(packageManager, ['install'], {
cwd: destination,
printOutput: ({ stdout, stderr }) => {
log.error(stdout.concat(stderr).toString());
},
}),
1000,
);
} catch (error: unknown) {
if (error instanceof ChildProcessError) {
installingSpinner.stop(
`Could not install dependencies: ${error.message}`,
error.code ?? undefined,
);
process.exit(error.code ?? 1);
} else {
throw error;
}
}
installingSpinner.stop('Dependencies installed');
}
note(
`cd ./${nodeName} && ${packageManager} run dev
📚 Documentation: https://docs.n8n.io/integrations/creating-nodes/build/${type}-style-node/
💬 Community: https://community.n8n.io`,
'Next Steps',
);
outro(`Created ./${nodeName}`);
}
}
@@ -0,0 +1,98 @@
import { select, text } from '@clack/prompts';
import { templates } from '../../template/templates';
import { withCancelHandler } from '../../utils/prompts';
import { validateNodeName } from '../../utils/validation';
export const nodeNamePrompt = async () =>
await withCancelHandler(
text({
message: "Package name (must start with 'n8n-nodes-' or '@org/n8n-nodes-')",
placeholder: 'n8n-nodes-my-app',
validate: validateNodeName,
defaultValue: 'n8n-nodes-my-app',
}),
);
export const nodeTypePrompt = async () =>
await withCancelHandler(
select<'declarative' | 'programmatic'>({
message: 'What kind of node are you building?',
options: [
{
label: 'HTTP API',
value: 'declarative',
hint: 'Low-code, faster approval for n8n Cloud',
},
{
label: 'Programmatic',
value: 'programmatic',
hint: 'Programmatic node with full flexibility',
},
],
initialValue: 'declarative',
}),
);
export const declarativeTemplatePrompt = async () =>
await withCancelHandler(
select<keyof typeof templates.declarative>({
message: 'What template do you want to use?',
options: Object.entries(templates.declarative).map(([value, template]) => ({
value: value as keyof typeof templates.declarative,
label: template.name,
hint: template.description,
})),
initialValue: 'githubIssues',
}),
);
export const programmaticNodeTypePrompt = async () =>
await withCancelHandler(
select<'basic' | 'chatModel' | 'chatMemory'>({
message: 'What type of programmatic node are you building?',
options: [
{
label: 'Basic',
value: 'basic',
hint: 'Standard programmatic node',
},
{
label: 'Chat Model (preview)',
value: 'chatModel',
hint: 'AI chat model node',
},
{
label: 'Chat Memory (preview)',
value: 'chatMemory',
hint: 'AI chat memory node',
},
],
initialValue: 'basic',
}),
);
export const chatModelTypePrompt = async () =>
await withCancelHandler(
select<'openaiCompatible' | 'custom' | 'customExample'>({
message: 'What type of chat model?',
options: [
{
label: 'OpenAI compatible',
value: 'openaiCompatible',
hint: 'Chat model for OpenAI-compatible providers',
},
{
label: 'Custom',
value: 'custom',
hint: 'Custom chat model implementation',
},
{
label: 'Custom Example',
value: 'customExample',
hint: 'OpenAI chat model example implementation',
},
],
initialValue: 'openaiCompatible',
}),
);
@@ -0,0 +1,9 @@
import { detectPackageManagerFromUserAgent } from '../../utils/package-manager';
import { getCommandHeader } from '../../utils/prompts';
export const createIntro = async () => {
const maybePackageManager = detectPackageManagerFromUserAgent();
const packageManager = maybePackageManager ?? 'npm';
const commandName = maybePackageManager ? `${packageManager} create @n8n/node` : 'n8n-node new';
return await getCommandHeader(commandName);
};
@@ -0,0 +1,35 @@
import { CommandTester } from '../test-utils/command-tester';
describe('prerelease command', () => {
const originalEnv = process.env;
const mockProcessStdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.RELEASE_MODE;
});
afterEach(() => {
process.env = originalEnv;
});
test('without RELEASE_MODE - exits with error and shows message', async () => {
await expect(CommandTester.run('prerelease')).rejects.toThrow('EEXIT: 1');
const stdoutCalls = mockProcessStdout.mock.calls.flat();
const hasReleaseMessage = stdoutCalls.some(
(call) => typeof call === 'string' && call.includes('run release` to publish the package'),
);
expect(hasReleaseMessage).toBe(true);
});
test('with RELEASE_MODE - succeeds without logging', async () => {
process.env.RELEASE_MODE = 'true';
const result = await CommandTester.run('prerelease');
expect(result).toBeDefined();
expect(mockProcessStdout).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,22 @@
import { Command } from '@oclif/core';
import { detectPackageManager } from '../utils/package-manager';
export default class Prerelease extends Command {
static override description =
'Only for internal use. Prevent npm publish, instead require npm run release';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {};
static override hidden = true;
async run(): Promise<void> {
await this.parse(Prerelease);
const packageManager = (await detectPackageManager()) ?? 'npm';
if (!process.env.RELEASE_MODE) {
this.log(`Run \`${packageManager} run release\` to publish the package`);
process.exit(1);
}
}
}
@@ -0,0 +1,83 @@
import fs from 'node:fs/promises';
import { CommandTester } from '../test-utils/command-tester';
import { mockSpawn } from '../test-utils/mock-child-process';
import { tmpdirTest } from '../test-utils/temp-fs';
describe('release command', () => {
const originalEnv = process.env;
const releaseItArgs = [
'exec',
'--',
'release-it',
'-n',
'--git.requireBranch main',
'--git.requireCleanWorkingDir',
'--git.requireUpstream',
'--git.requireCommits',
'--git.commit',
'--git.tag',
'--git.push',
'--git.changelog="npx auto-changelog --stdout --unreleased --commit-limit false -u --hide-credit"',
'--github.release',
];
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.npm_config_user_agent;
});
afterEach(() => {
process.env = originalEnv;
});
tmpdirTest('successful release - runs release-it with correct arguments', async ({ tmpdir }) => {
await fs.writeFile(
`${tmpdir}/package.json`,
JSON.stringify({
name: 'test-node',
version: '1.0.0',
n8n: {
nodes: ['dist/nodes/TestNode.node.js'],
},
}),
);
await fs.writeFile(`${tmpdir}/pnpm-lock.yaml`, '# pnpm lock file');
mockSpawn(
'pnpm',
[
...releaseItArgs,
'--hooks.before:init="pnpm run lint && pnpm run build"',
'--hooks.after:bump="npx auto-changelog -p"',
],
{ exitCode: 0 },
);
const result = await CommandTester.run('release');
expect(result).toBeDefined();
});
tmpdirTest('release-it failure - exits with error code', async ({ tmpdir }) => {
await fs.writeFile(
`${tmpdir}/package.json`,
JSON.stringify({
name: 'test-node',
version: '1.0.0',
n8n: {
nodes: ['dist/nodes/TestNode.node.js'],
},
}),
);
mockSpawn('npm', expect.any(Array) as string[], {
exitCode: 1,
stderr: 'Release failed: Git working directory is not clean',
});
await expect(CommandTester.run('release')).rejects.toThrow('EEXIT: 1');
});
});
@@ -0,0 +1,56 @@
import { intro } from '@clack/prompts';
import { Command } from '@oclif/core';
import { ChildProcessError, runCommand } from '../utils/child-process';
import { detectPackageManager } from '../utils/package-manager';
import { getCommandHeader } from '../utils/prompts';
export default class Release extends Command {
static override description = 'Publish your community node package to npm';
static override examples = ['<%= config.bin %> <%= command.id %>'];
static override flags = {};
async run(): Promise<void> {
await this.parse(Release);
intro(await getCommandHeader('n8n-node release'));
const pm = (await detectPackageManager()) ?? 'npm';
try {
await runCommand(
'release-it',
[
'-n',
'--git.requireBranch main',
'--git.requireCleanWorkingDir',
'--git.requireUpstream',
'--git.requireCommits',
'--git.commit',
'--git.tag',
'--git.push',
'--git.changelog="npx auto-changelog --stdout --unreleased --commit-limit false -u --hide-credit"',
'--github.release',
`--hooks.before:init="${pm} run lint && ${pm} run build"`,
'--hooks.after:bump="npx auto-changelog -p"',
],
{
stdio: 'inherit',
context: 'local',
env: {
RELEASE_MODE: 'true',
},
},
);
} catch (error) {
if (error instanceof ChildProcessError) {
if (error.signal) {
process.kill(process.pid, error.signal);
} else {
process.exit(error.code ?? 0);
}
}
throw error;
}
}
}