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,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;
}