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",
"version": "0.7.0",
"version": "0.8.0",
"description": "Official CLI to create new community nodes for n8n",
"bin": {
"create-node": "bin/create-node.cjs"

View File

@@ -1,6 +1,6 @@
{
"name": "@n8n/node-cli",
"version": "0.7.0",
"version": "0.8.0",
"description": "Official CLI for developing community nodes for n8n",
"bin": {
"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 os from 'node:os';
import path from 'node:path';
@@ -29,7 +29,7 @@ export default class Dev extends Command {
'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.',
'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,10 +74,10 @@ export default class Dev extends Command {
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
// TODO: Use n8n@latest. Currently using n8n@next because of broken hot reloading before n8n@1.111.0
try {
await Promise.race([
new Promise<void>((resolve) => {
runPersistentCommand('npx', ['-y', '--quiet', '--prefer-online', 'n8n@next'], {
runPersistentCommand('npx', ['-y', '--quiet', '--prefer-online', 'n8n@latest'], {
cwd: n8nUserFolder,
env: {
...process.env,
@@ -85,7 +85,6 @@ export default class Dev extends Command {
N8N_RUNNERS_ENABLED: 'true',
DB_SQLITE_POOL_SIZE: '10',
N8N_USER_FOLDER: n8nUserFolder,
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: 'false',
},
name: 'n8n',
color: picocolors.green,
@@ -101,7 +100,6 @@ export default class Dev extends Command {
new Promise<void>((_, reject) => {
setTimeout(() => {
const error = new Error('n8n startup timeout after 120 seconds');
onCancel(error.message);
reject(error);
}, 120_000);
}),
@@ -109,6 +107,11 @@ export default class Dev extends Command {
setupComplete = true;
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');

View File

@@ -7,22 +7,100 @@ import { jsonParse } from '../../utils/json';
export function commands() {
const childProcesses: ChildProcess[] = [];
let isShuttingDown = false;
function registerChild(child: ChildProcess) {
const registerChild = (child: ChildProcess): void => {
childProcesses.push(child);
}
};
function cleanup(signal: 'SIGINT' | 'SIGTERM') {
for (const child of childProcesses) {
const killChild = (child: ChildProcess, signal: NodeJS.Signals): void => {
if (!child.killed) {
child.kill(signal);
}
};
const forceKillAllChildren = (): void => {
childProcesses.forEach((child) => killChild(child, 'SIGKILL'));
process.exit(1);
};
const gracefulShutdown = (signal: 'SIGINT' | 'SIGTERM'): void => {
if (childProcesses.length === 0) {
process.exit();
return;
}
process.on('SIGINT', () => cleanup('SIGINT'));
process.on('SIGTERM', () => cleanup('SIGTERM'));
let exitedCount = 0;
const totalChildren = childProcesses.length;
function runPersistentCommand(
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,
args: string[],
opts: {
@@ -32,7 +110,7 @@ export function commands() {
color?: Formatter;
allowOutput?: (line: string) => boolean;
} = {},
) {
): ChildProcess => {
const child = spawn(cmd, args, {
cwd: opts.cwd,
env: { ...process.env, ...opts.env },
@@ -41,46 +119,21 @@ export function commands() {
registerChild(child);
function stripClearCodes(input: string): string {
// Remove clear screen/reset ANSI codes
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 logger = createLogger(opts.name, opts.color, opts.allowOutput);
const handleOutput = (data: Buffer) => processOutput(data, logger);
const log = (text: string) => {
if (opts.allowOutput && !opts.allowOutput(text)) return;
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.stdout?.on('data', handleOutput);
child.stderr?.on('data', handleOutput);
child.on('close', (code) => {
if (!isShuttingDown) {
console.log(`${opts.name ?? cmd} exited with code ${code}`);
process.exit(code);
process.exit(code ?? 0);
}
});
return child;
}
};
return {
runPersistentCommand,

View File

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