diff --git a/packages/@n8n/create-node/package.json b/packages/@n8n/create-node/package.json index 6d803c48f4..61fbcc94f5 100644 --- a/packages/@n8n/create-node/package.json +++ b/packages/@n8n/create-node/package.json @@ -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" diff --git a/packages/@n8n/node-cli/package.json b/packages/@n8n/node-cli/package.json index e00123e856..82c1064b72 100644 --- a/packages/@n8n/node-cli/package.json +++ b/packages/@n8n/node-cli/package.json @@ -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" diff --git a/packages/@n8n/node-cli/src/commands/dev/index.ts b/packages/@n8n/node-cli/src/commands/dev/index.ts index 65c7812281..3d3c253707 100644 --- a/packages/@n8n/node-cli/src/commands/dev/index.ts +++ b/packages/@n8n/node-cli/src/commands/dev/index.ts @@ -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,41 +74,44 @@ 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 - await Promise.race([ - new Promise((resolve) => { - runPersistentCommand('npx', ['-y', '--quiet', '--prefer-online', 'n8n@next'], { - cwd: n8nUserFolder, - env: { - ...process.env, - N8N_DEV_RELOAD: 'true', - N8N_RUNNERS_ENABLED: 'true', - DB_SQLITE_POOL_SIZE: '10', - N8N_USER_FOLDER: n8nUserFolder, - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: 'false', - }, - name: 'n8n', - color: picocolors.green, - allowOutput: (line) => { - if (line.includes('Initializing n8n process')) { - resolve(); - } + try { + await Promise.race([ + new Promise((resolve) => { + runPersistentCommand('npx', ['-y', '--quiet', '--prefer-online', 'n8n@latest'], { + cwd: n8nUserFolder, + env: { + ...process.env, + N8N_DEV_RELOAD: 'true', + N8N_RUNNERS_ENABLED: 'true', + DB_SQLITE_POOL_SIZE: '10', + N8N_USER_FOLDER: n8nUserFolder, + }, + name: 'n8n', + color: picocolors.green, + allowOutput: (line) => { + if (line.includes('Initializing n8n process')) { + resolve(); + } - return setupComplete; - }, - }); - }), - new Promise((_, reject) => { - setTimeout(() => { - const error = new Error('n8n startup timeout after 120 seconds'); - onCancel(error.message); - reject(error); - }, 120_000); - }), - ]); + return setupComplete; + }, + }); + }), + new Promise((_, reject) => { + setTimeout(() => { + const error = new Error('n8n startup timeout after 120 seconds'); + reject(error); + }, 120_000); + }), + ]); - setupComplete = true; - npxN8nSpinner.stop('Started n8n dev server'); + 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'); diff --git a/packages/@n8n/node-cli/src/commands/dev/utils.ts b/packages/@n8n/node-cli/src/commands/dev/utils.ts index 09d5059fd6..532fd05de3 100644 --- a/packages/@n8n/node-cli/src/commands/dev/utils.ts +++ b/packages/@n8n/node-cli/src/commands/dev/utils.ts @@ -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); } - process.exit(); - } + }; - process.on('SIGINT', () => cleanup('SIGINT')); - process.on('SIGTERM', () => cleanup('SIGTERM')); + const forceKillAllChildren = (): void => { + 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, 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) => { - console.log(`${opts.name ?? cmd} exited with code ${code}`); - process.exit(code); + if (!isShuttingDown) { + console.log(`${opts.name ?? cmd} exited with code ${code}`); + process.exit(code ?? 0); + } }); return child; - } + }; return { runPersistentCommand, diff --git a/packages/@n8n/node-cli/src/commands/new/prompts.ts b/packages/@n8n/node-cli/src/commands/new/prompts.ts index 18ef9dcccb..2bae1076c1 100644 --- a/packages/@n8n/node-cli/src/commands/new/prompts.ts +++ b/packages/@n8n/node-cli/src/commands/new/prompts.ts @@ -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', }), ); diff --git a/packages/@n8n/node-cli/src/utils/validation.ts b/packages/@n8n/node-cli/src/utils/validation.ts index 95bf5ead7a..76295bc0ae 100644 --- a/packages/@n8n/node-cli/src/utils/validation.ts +++ b/packages/@n8n/node-cli/src/utils/validation.ts @@ -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; };