fix: Improve Node CLI error messages and dev command exit behavior (#19637)

This commit is contained in:
Elias Meire
2025-09-17 14:10:27 +02:00
committed by GitHub
parent 170796f254
commit 14a7c36673
6 changed files with 141 additions and 85 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@n8n/create-node", "name": "@n8n/create-node",
"version": "0.7.0", "version": "0.8.0",
"description": "Official CLI to create new community nodes for n8n", "description": "Official CLI to create new community nodes for n8n",
"bin": { "bin": {
"create-node": "bin/create-node.cjs" "create-node": "bin/create-node.cjs"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@n8n/node-cli", "name": "@n8n/node-cli",
"version": "0.7.0", "version": "0.8.0",
"description": "Official CLI for developing community nodes for n8n", "description": "Official CLI for developing community nodes for n8n",
"bin": { "bin": {
"n8n-node": "bin/n8n-node.mjs" "n8n-node": "bin/n8n-node.mjs"

View File

@@ -1,4 +1,4 @@
import { intro, outro, spinner, log } from '@clack/prompts'; import { intro, log, outro, spinner } from '@clack/prompts';
import { Command, Flags } from '@oclif/core'; import { Command, Flags } from '@oclif/core';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
@@ -29,7 +29,7 @@ export default class Dev extends Command {
'custom-user-folder': Flags.directory({ 'custom-user-folder': Flags.directory({
default: path.join(os.homedir(), '.n8n-node-cli'), default: path.join(os.homedir(), '.n8n-node-cli'),
description: description:
'Folder to use to store user-specific n8n data. By default it will use ~/.n8n-node-cli.', '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.',
}), }),
}; };
@@ -74,41 +74,44 @@ export default class Dev extends Command {
log.warn(picocolors.dim('First run may take a few minutes while dependencies are installed')); log.warn(picocolors.dim('First run may take a few minutes while dependencies are installed'));
// Run n8n with hot reload enabled, always attempt to use latest n8n // Run n8n with hot reload enabled, always attempt to use latest n8n
// TODO: Use n8n@latest. Currently using n8n@next because of broken hot reloading before n8n@1.111.0 try {
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
runPersistentCommand('npx', ['-y', '--quiet', '--prefer-online', 'n8n@next'], { runPersistentCommand('npx', ['-y', '--quiet', '--prefer-online', 'n8n@latest'], {
cwd: n8nUserFolder, cwd: n8nUserFolder,
env: { env: {
...process.env, ...process.env,
N8N_DEV_RELOAD: 'true', N8N_DEV_RELOAD: 'true',
N8N_RUNNERS_ENABLED: 'true', N8N_RUNNERS_ENABLED: 'true',
DB_SQLITE_POOL_SIZE: '10', DB_SQLITE_POOL_SIZE: '10',
N8N_USER_FOLDER: n8nUserFolder, N8N_USER_FOLDER: n8nUserFolder,
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: 'false', },
}, name: 'n8n',
name: 'n8n', color: picocolors.green,
color: picocolors.green, allowOutput: (line) => {
allowOutput: (line) => { if (line.includes('Initializing n8n process')) {
if (line.includes('Initializing n8n process')) { resolve();
resolve(); }
}
return setupComplete; return setupComplete;
}, },
}); });
}), }),
new Promise<void>((_, reject) => { new Promise<void>((_, reject) => {
setTimeout(() => { setTimeout(() => {
const error = new Error('n8n startup timeout after 120 seconds'); const error = new Error('n8n startup timeout after 120 seconds');
onCancel(error.message); reject(error);
reject(error); }, 120_000);
}, 120_000); }),
}), ]);
]);
setupComplete = true; setupComplete = true;
npxN8nSpinner.stop('Started n8n dev server'); npxN8nSpinner.stop('Started n8n dev server');
} catch (error) {
npxN8nSpinner.stop('Failed to start n8n dev server');
onCancel(error instanceof Error ? error.message : 'Unknown error occurred', 1);
return;
}
} }
outro('✓ Setup complete'); outro('✓ Setup complete');

View File

@@ -7,22 +7,100 @@ import { jsonParse } from '../../utils/json';
export function commands() { export function commands() {
const childProcesses: ChildProcess[] = []; const childProcesses: ChildProcess[] = [];
let isShuttingDown = false;
function registerChild(child: ChildProcess) { const registerChild = (child: ChildProcess): void => {
childProcesses.push(child); childProcesses.push(child);
} };
function cleanup(signal: 'SIGINT' | 'SIGTERM') { const killChild = (child: ChildProcess, signal: NodeJS.Signals): void => {
for (const child of childProcesses) { if (!child.killed) {
child.kill(signal); child.kill(signal);
} }
process.exit(); };
}
process.on('SIGINT', () => cleanup('SIGINT')); const forceKillAllChildren = (): void => {
process.on('SIGTERM', () => cleanup('SIGTERM')); childProcesses.forEach((child) => killChild(child, 'SIGKILL'));
process.exit(1);
};
function runPersistentCommand( const gracefulShutdown = (signal: 'SIGINT' | 'SIGTERM'): void => {
if (childProcesses.length === 0) {
process.exit();
return;
}
let exitedCount = 0;
const totalChildren = childProcesses.length;
const forceExitTimer = setTimeout(forceKillAllChildren, 5000);
const onChildExit = () => {
exitedCount++;
if (exitedCount === totalChildren) {
clearTimeout(forceExitTimer);
process.exit();
}
};
childProcesses.forEach((child) => {
if (!child.killed) {
child.once('exit', onChildExit);
killChild(child, signal);
// Escalate to SIGKILL after 5 seconds
setTimeout(() => killChild(child, 'SIGKILL'), 5000);
} else {
onChildExit(); // Process already dead
}
});
};
const handleSignal = (signal: 'SIGINT' | 'SIGTERM'): void => {
if (isShuttingDown) {
// Second signal - force kill immediately
console.log('\nForce killing processes...');
forceKillAllChildren();
return;
}
isShuttingDown = true;
if (signal === 'SIGINT') {
console.log('\nShutting down gracefully... (press Ctrl+C again to force quit)');
}
gracefulShutdown(signal);
};
process.on('SIGINT', () => handleSignal('SIGINT'));
process.on('SIGTERM', () => handleSignal('SIGTERM'));
const stripAnsiCodes = (input: string): string =>
input
.replace(/\x1Bc/g, '') // Full reset
.replace(/\x1B\[2J/g, '') // Clear screen
.replace(/\x1B\[3J/g, '') // Clear scrollback
.replace(/\x1B\[H/g, '') // Move cursor to top-left
.replace(/\x1B\[0?m/g, ''); // Reset colors
const createLogger =
(name?: string, color?: Formatter, allowOutput?: (line: string) => boolean) =>
(text: string): void => {
if (allowOutput && !allowOutput(text)) return;
const prefix = name ? (color ? color(`[${name}]`) : `[${name}]`) : '';
console.log(prefix ? `${prefix} ${text}` : text);
};
const processOutput = (data: Buffer, logger: (text: string) => void): void => {
data
.toString()
.split('\n')
.map((line) => stripAnsiCodes(line).trim())
.filter(Boolean)
.forEach(logger);
};
const runPersistentCommand = (
cmd: string, cmd: string,
args: string[], args: string[],
opts: { opts: {
@@ -32,7 +110,7 @@ export function commands() {
color?: Formatter; color?: Formatter;
allowOutput?: (line: string) => boolean; allowOutput?: (line: string) => boolean;
} = {}, } = {},
) { ): ChildProcess => {
const child = spawn(cmd, args, { const child = spawn(cmd, args, {
cwd: opts.cwd, cwd: opts.cwd,
env: { ...process.env, ...opts.env }, env: { ...process.env, ...opts.env },
@@ -41,46 +119,21 @@ export function commands() {
registerChild(child); registerChild(child);
function stripClearCodes(input: string): string { const logger = createLogger(opts.name, opts.color, opts.allowOutput);
// Remove clear screen/reset ANSI codes const handleOutput = (data: Buffer) => processOutput(data, logger);
return input
.replace(/\x1Bc/g, '') // Full reset
.replace(/\x1B\[2J/g, '') // Clear screen
.replace(/\x1B\[3J/g, '') // Clear scrollback
.replace(/\x1B\[H/g, '') // Move cursor to top-left
.replace(/\x1B\[0?m/g, ''); // Reset colors
}
const log = (text: string) => { child.stdout?.on('data', handleOutput);
if (opts.allowOutput && !opts.allowOutput(text)) return; child.stderr?.on('data', handleOutput);
if (opts.name) {
const rawPrefix = `[${opts.name}]`;
const prefix = opts.color ? opts.color(rawPrefix) : rawPrefix;
console.log(`${prefix} ${text}`);
} else {
console.log(text);
}
};
const handleOutput = (data: Buffer): void => {
data
.toString()
.split('\n')
.map((line) => stripClearCodes(line).trim())
.filter(Boolean)
.forEach((line) => log(line));
};
child.stdout.on('data', handleOutput);
child.stderr.on('data', handleOutput);
child.on('close', (code) => { child.on('close', (code) => {
console.log(`${opts.name ?? cmd} exited with code ${code}`); if (!isShuttingDown) {
process.exit(code); console.log(`${opts.name ?? cmd} exited with code ${code}`);
process.exit(code ?? 0);
}
}); });
return child; return child;
} };
return { return {
runPersistentCommand, runPersistentCommand,

View File

@@ -7,10 +7,10 @@ import { validateNodeName } from '../../utils/validation';
export const nodeNamePrompt = async () => export const nodeNamePrompt = async () =>
await withCancelHandler( await withCancelHandler(
text({ text({
message: 'What is your node called?', message: "Package name (must start with 'n8n-nodes-' or '@org/n8n-nodes-')",
placeholder: 'n8n-nodes-example', placeholder: 'n8n-nodes-my-app',
validate: validateNodeName, validate: validateNodeName,
defaultValue: 'n8n-nodes-example', defaultValue: 'n8n-nodes-my-app',
}), }),
); );

View File

@@ -7,7 +7,7 @@ export const validateNodeName = (name: string): string | undefined => {
const regexUnscoped = /^n8n-nodes-([a-z0-9]+(?:-[a-z0-9]+)*)$/; const regexUnscoped = /^n8n-nodes-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
if (!regexScoped.test(name) && !regexUnscoped.test(name)) { if (!regexScoped.test(name) && !regexUnscoped.test(name)) {
return 'Node name should be in the format @org/n8n-nodes-example or n8n-nodes-example'; return "Must start with 'n8n-nodes-' or '@org/n8n-nodes-'. Examples: n8n-nodes-my-app, @mycompany/n8n-nodes-my-app";
} }
return; return;
}; };