diff --git a/src/commands/dev.ts b/src/commands/dev.ts new file mode 100644 index 0000000..aba5ec5 --- /dev/null +++ b/src/commands/dev.ts @@ -0,0 +1,28 @@ +import { Command } from 'commander'; +import { + superviseDevProcesses, + type DevOrchestratorOptions, +} from '../dev/orchestrator.js'; +import { resolveDevProcessPlan, type DevProcessSpec } from '../dev/project.js'; +import { TerminalOutput } from '../output/terminal.js'; + +export interface DevCommandOptions extends DevOrchestratorOptions { + readonly cwd?: () => string; + readonly resolvePlan?: (cwd: string) => readonly DevProcessSpec[]; +} + +export function createDevCommand(options: DevCommandOptions = {}): Command { + const cwd = options.cwd ?? process.cwd; + const output = options.output ?? new TerminalOutput(); + const resolvePlan = options.resolvePlan ?? resolveDevProcessPlan; + + return new Command('dev') + .description('Start development processes for a StellarForge project.') + .action(async () => { + const plan = resolvePlan(cwd()); + output.info( + `Starting ${plan.length} development process${plan.length === 1 ? '' : 'es'}.`, + ); + await superviseDevProcesses(plan, { ...options, output }); + }); +} diff --git a/src/commands/root.ts b/src/commands/root.ts index 1396321..52c4059 100644 --- a/src/commands/root.ts +++ b/src/commands/root.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { resolveCliVersion } from '../version.js'; +import { createDevCommand } from './dev.js'; import { createDoctorCommand } from './doctor.js'; import { createNewCommand } from './new.js'; @@ -16,5 +17,6 @@ export function createRootCommand(version = resolveCliVersion()): Command { .showSuggestionAfterError(true) .action(() => undefined) .addCommand(createDoctorCommand()) - .addCommand(createNewCommand()); + .addCommand(createNewCommand()) + .addCommand(createDevCommand()); } diff --git a/src/dev/environment.ts b/src/dev/environment.ts new file mode 100644 index 0000000..e131683 --- /dev/null +++ b/src/dev/environment.ts @@ -0,0 +1,41 @@ +const SAFE_ENVIRONMENT_KEYS = [ + 'PATH', + 'Path', + 'PATHEXT', + 'SystemRoot', + 'ComSpec', + 'HOME', + 'USERPROFILE', + 'TMPDIR', + 'TMP', + 'TEMP', + 'TERM', + 'COLORTERM', + 'NO_COLOR', + 'FORCE_COLOR', + 'CI', + 'NODE_ENV', + 'PORT', + 'API_HOST', + 'API_PORT', + 'BACKEND_PORT', + 'STELLAR_NETWORK', + 'STELLAR_HORIZON_URL', + 'STELLAR_RPC_URL', +] as const; + +export function buildSafeChildEnvironment( + source: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = {}; + + for (const key of SAFE_ENVIRONMENT_KEYS) { + const value = source[key]; + + if (value !== undefined) { + environment[key] = value; + } + } + + return environment; +} diff --git a/src/dev/orchestrator.ts b/src/dev/orchestrator.ts new file mode 100644 index 0000000..27f916c --- /dev/null +++ b/src/dev/orchestrator.ts @@ -0,0 +1,171 @@ +import { spawn } from 'node:child_process'; +import { SubprocessCliError, ValidationCliError } from '../errors/errors.js'; +import { TerminalOutput } from '../output/terminal.js'; +import { buildSafeChildEnvironment } from './environment.js'; +import type { DevProcessSpec } from './project.js'; + +interface ManagedStream { + on(event: 'data', listener: (chunk: Buffer | string) => void): unknown; +} + +export interface ManagedChildProcess { + readonly stdout: ManagedStream; + readonly stderr: ManagedStream; + once(event: 'error', listener: (error: Error) => void): ManagedChildProcess; + once( + event: 'exit', + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): ManagedChildProcess; + kill(signal?: NodeJS.Signals): boolean; +} + +export type DevProcessSpawner = ( + spec: DevProcessSpec, + environment: NodeJS.ProcessEnv, +) => ManagedChildProcess; + +export interface DevSignalSource { + once(signal: NodeJS.Signals, listener: () => void): unknown; + removeListener(signal: NodeJS.Signals, listener: () => void): unknown; +} + +export interface DevOrchestratorOptions { + readonly output?: TerminalOutput; + readonly environment?: NodeJS.ProcessEnv; + readonly spawnProcess?: DevProcessSpawner; + readonly signalSource?: DevSignalSource; +} + +function defaultSpawnProcess( + spec: DevProcessSpec, + environment: NodeJS.ProcessEnv, +): ManagedChildProcess { + return spawn(spec.command, [...spec.args], { + cwd: spec.cwd, + env: environment, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }) as unknown as ManagedChildProcess; +} + +function writePrefixedLines( + output: TerminalOutput, + label: string, + chunk: Buffer | string, + isError: boolean, +): void { + for (const line of String(chunk).split(/\r?\n/)) { + if (line.length === 0) { + continue; + } + + const message = `[${label}] ${line}`; + if (isError) { + output.warning(message); + } else { + output.info(message); + } + } +} + +export async function superviseDevProcesses( + specs: readonly DevProcessSpec[], + options: DevOrchestratorOptions = {}, +): Promise { + if (specs.length === 0) { + throw new ValidationCliError('No development processes are configured.'); + } + + const output = options.output ?? new TerminalOutput(); + const environment = buildSafeChildEnvironment( + options.environment ?? process.env, + ); + const spawnProcess = options.spawnProcess ?? defaultSpawnProcess; + const signalSource = options.signalSource ?? process; + const children: ManagedChildProcess[] = []; + let shuttingDown = false; + + const stopChildren = (signal: NodeJS.Signals): void => { + if (shuttingDown) { + return; + } + + shuttingDown = true; + for (const child of children) { + child.kill(signal); + } + }; + + const handleSigint = (): void => stopChildren('SIGINT'); + const handleSigterm = (): void => stopChildren('SIGTERM'); + + signalSource.once('SIGINT', handleSigint); + signalSource.once('SIGTERM', handleSigterm); + + try { + await new Promise((resolve, reject) => { + let remaining = specs.length; + let settled = false; + + const fail = (message: string, cause?: Error): void => { + if (settled) { + return; + } + + settled = true; + stopChildren('SIGTERM'); + reject( + new SubprocessCliError( + message, + cause === undefined ? undefined : { cause }, + ), + ); + }; + + for (const spec of specs) { + let child: ManagedChildProcess; + + try { + child = spawnProcess(spec, environment); + } catch (error) { + fail( + `Failed to start development process '${spec.label}'.`, + error instanceof Error ? error : undefined, + ); + return; + } + + children.push(child); + child.stdout.on('data', (chunk) => + writePrefixedLines(output, spec.label, chunk, false), + ); + child.stderr.on('data', (chunk) => + writePrefixedLines(output, spec.label, chunk, true), + ); + child.once('error', (error) => { + fail(`Development process '${spec.label}' failed to start.`, error); + }); + child.once('exit', (code, signal) => { + remaining -= 1; + + if (!shuttingDown && code !== 0) { + fail( + `Development process '${spec.label}' exited unexpectedly${ + code === null ? '' : ` with code ${code}` + }${signal === null ? '' : ` after ${signal}`}.`, + ); + return; + } + + if (!settled && remaining === 0) { + settled = true; + resolve(); + } + }); + } + }); + } finally { + signalSource.removeListener('SIGINT', handleSigint); + signalSource.removeListener('SIGTERM', handleSigterm); + } +} diff --git a/src/dev/project.ts b/src/dev/project.ts new file mode 100644 index 0000000..4534d1b --- /dev/null +++ b/src/dev/project.ts @@ -0,0 +1,75 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ValidationCliError } from '../errors/errors.js'; + +export interface DevProcessSpec { + readonly label: string; + readonly command: string; + readonly args: readonly string[]; + readonly cwd: string; +} + +interface ProjectPackage { + readonly name?: unknown; + readonly scripts?: unknown; +} + +function readProjectPackage(projectRoot: string): ProjectPackage { + const packagePath = resolve(projectRoot, 'package.json'); + + if (!existsSync(packagePath)) { + throw new ValidationCliError( + 'No package.json was found. The dev command currently supports generated Node.js StellarForge projects.', + ); + } + + try { + return JSON.parse(readFileSync(packagePath, 'utf8')) as ProjectPackage; + } catch (error) { + throw new ValidationCliError('package.json is not valid JSON.', { + cause: error, + }); + } +} + +function selectDevelopmentScript(scripts: Record): string { + for (const candidate of ['dev', 'start', 'start:backend']) { + if (typeof scripts[candidate] === 'string') { + return candidate; + } + } + + throw new ValidationCliError( + 'No supported development script was found. Expected one of: dev, start, start:backend.', + ); +} + +export function resolveDevProcessPlan(cwd: string): readonly DevProcessSpec[] { + const projectRoot = resolve(cwd); + const projectPackage = readProjectPackage(projectRoot); + + if ( + projectPackage.scripts === null || + typeof projectPackage.scripts !== 'object' || + Array.isArray(projectPackage.scripts) + ) { + throw new ValidationCliError('package.json must define a scripts object.'); + } + + const script = selectDevelopmentScript( + projectPackage.scripts as Record, + ); + const label = + typeof projectPackage.name === 'string' && projectPackage.name.length > 0 + ? projectPackage.name + : 'app'; + + return [ + { + label, + command: process.platform === 'win32' ? 'npm.cmd' : 'npm', + args: ['run', script], + cwd: projectRoot, + }, + ]; +} diff --git a/src/main.ts b/src/main.ts index 9507be0..391f5d1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { createRootCommand } from './commands/root.js'; -export function runCli(argv: string[] = process.argv): void { +export async function runCli(argv: string[] = process.argv): Promise { const program = createRootCommand(); - program.parse(argv); + await program.parseAsync(argv); } diff --git a/tests/dev-orchestrator.test.ts b/tests/dev-orchestrator.test.ts new file mode 100644 index 0000000..6d55592 --- /dev/null +++ b/tests/dev-orchestrator.test.ts @@ -0,0 +1,176 @@ +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { + superviseDevProcesses, + type DevSignalSource, + type ManagedChildProcess, +} from '../src/dev/orchestrator.js'; +import type { DevProcessSpec } from '../src/dev/project.js'; +import { SubprocessCliError } from '../src/errors/errors.js'; +import { createCapturedTerminalOutput } from './helpers/index.js'; + +class FakeChild implements ManagedChildProcess { + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly killedSignals: NodeJS.Signals[] = []; + #errorListener: ((error: Error) => void) | undefined; + #exitListener: + ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined; + + once(event: 'error', listener: (error: Error) => void): this; + once( + event: 'exit', + listener: (code: number | null, signal: NodeJS.Signals | null) => void, + ): this; + once( + event: 'error' | 'exit', + listener: + | ((error: Error) => void) + | ((code: number | null, signal: NodeJS.Signals | null) => void), + ): this { + if (event === 'error') { + this.#errorListener = listener as (error: Error) => void; + } else { + this.#exitListener = listener as ( + code: number | null, + signal: NodeJS.Signals | null, + ) => void; + } + + return this; + } + + kill(signal: NodeJS.Signals = 'SIGTERM'): boolean { + this.killedSignals.push(signal); + queueMicrotask(() => this.emitExit(null, signal)); + return true; + } + + emitError(error: Error): void { + this.#errorListener?.(error); + } + + emitExit(code: number | null, signal: NodeJS.Signals | null): void { + this.#exitListener?.(code, signal); + } +} + +class FakeSignalSource implements DevSignalSource { + readonly #listeners = new Map void>(); + + once(signal: NodeJS.Signals, listener: () => void): void { + this.#listeners.set(signal, listener); + } + + removeListener(signal: NodeJS.Signals, listener: () => void): void { + if (this.#listeners.get(signal) === listener) { + this.#listeners.delete(signal); + } + } + + emit(signal: NodeJS.Signals): void { + const listener = this.#listeners.get(signal); + this.#listeners.delete(signal); + listener?.(); + } + + has(signal: NodeJS.Signals): boolean { + return this.#listeners.has(signal); + } +} + +const SPEC: DevProcessSpec = { + label: 'api', + command: 'npm', + args: ['run', 'start'], + cwd: '/workspace', +}; + +describe('development process supervisor', () => { + it('uses filtered environment values, prefixes output, and resolves on clean exit', async () => { + const child = new FakeChild(); + const captured = createCapturedTerminalOutput(); + let childEnvironment: NodeJS.ProcessEnv | undefined; + + const running = superviseDevProcesses([SPEC], { + output: captured.output, + environment: { + PATH: '/usr/bin', + STELLAR_NETWORK: 'TESTNET', + SECRET_TOKEN: 'must-not-propagate', + NODE_OPTIONS: '--require malicious.js', + }, + spawnProcess(_spec, environment) { + childEnvironment = environment; + return child; + }, + signalSource: new FakeSignalSource(), + }); + + child.stdout.write('ready\n'); + child.stderr.write('warning\n'); + child.emitExit(0, null); + await running; + + expect(childEnvironment?.PATH).toBe('/usr/bin'); + expect(childEnvironment?.STELLAR_NETWORK).toBe('TESTNET'); + expect(childEnvironment?.SECRET_TOKEN).toBeUndefined(); + expect(childEnvironment?.NODE_OPTIONS).toBeUndefined(); + expect(captured.stdoutText()).toContain('[api] ready'); + expect(captured.stderrText()).toContain('[api] warning'); + }); + + it.each(['SIGINT', 'SIGTERM'] as const)( + 'terminates children and removes signal handlers after %s', + async (signal) => { + const child = new FakeChild(); + const signals = new FakeSignalSource(); + const running = superviseDevProcesses([SPEC], { + spawnProcess: () => child, + signalSource: signals, + }); + + signals.emit(signal); + await running; + + expect(child.killedSignals).toEqual([signal]); + expect(signals.has('SIGINT')).toBe(false); + expect(signals.has('SIGTERM')).toBe(false); + }, + ); + + it('fails safely when a child cannot be started', async () => { + const child = new FakeChild(); + + await expect( + superviseDevProcesses([SPEC], { + spawnProcess() { + throw new Error('spawn failed with SECRET_TOKEN=hidden'); + }, + signalSource: new FakeSignalSource(), + }), + ).rejects.toBeInstanceOf(SubprocessCliError); + + expect(child.killedSignals).toEqual([]); + }); + + it('terminates sibling processes when one exits unexpectedly', async () => { + const first = new FakeChild(); + const second = new FakeChild(); + const children = [first, second]; + let index = 0; + + const running = superviseDevProcesses( + [SPEC, { ...SPEC, label: 'worker' }], + { + spawnProcess: () => children[index++]!, + signalSource: new FakeSignalSource(), + }, + ); + + first.emitExit(7, null); + + await expect(running).rejects.toThrow('exited unexpectedly with code 7'); + expect(second.killedSignals).toEqual(['SIGTERM']); + }); +}); diff --git a/tests/dev-project.test.ts b/tests/dev-project.test.ts new file mode 100644 index 0000000..a1736b0 --- /dev/null +++ b/tests/dev-project.test.ts @@ -0,0 +1,67 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ValidationCliError } from '../src/errors/errors.js'; +import { resolveDevProcessPlan } from '../src/dev/project.js'; +import { withTempDirectory } from './helpers/index.js'; + +describe('development project plan', () => { + it('selects a safe npm start script from a generated Node project', () => + withTempDirectory((root) => { + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: 'demo', + scripts: { start: 'node src/index.js' }, + }), + ); + + const plan = resolveDevProcessPlan(root); + + expect(plan).toHaveLength(1); + expect(plan[0]).toEqual({ + label: 'demo', + command: process.platform === 'win32' ? 'npm.cmd' : 'npm', + args: ['run', 'start'], + cwd: root, + }); + })); + + it('prefers an explicit dev script and never executes package script text itself', () => + withTempDirectory((root) => { + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: 'demo', + scripts: { + dev: 'node dev.js --flag && echo unsafe-if-shell-expanded', + start: 'node start.js', + }, + }), + ); + + const [spec] = resolveDevProcessPlan(root); + + expect(spec?.args).toEqual(['run', 'dev']); + expect(spec?.args).not.toContain( + 'node dev.js --flag && echo unsafe-if-shell-expanded', + ); + })); + + it('rejects unsupported or malformed project roots before spawning', () => + withTempDirectory((root) => { + expect(() => resolveDevProcessPlan(root)).toThrow(ValidationCliError); + + mkdirSync(join(root, 'nested')); + writeFileSync(join(root, 'package.json'), '{not-json'); + expect(() => resolveDevProcessPlan(root)).toThrow('not valid JSON'); + + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ scripts: {} }), + ); + expect(() => resolveDevProcessPlan(root)).toThrow( + 'No supported development script', + ); + })); +}); diff --git a/tests/root-command.test.ts b/tests/root-command.test.ts index fb1bf8d..70bfcce 100644 --- a/tests/root-command.test.ts +++ b/tests/root-command.test.ts @@ -9,6 +9,7 @@ describe('StellarForge root command', () => { expect(program.commands.map((command) => command.name())).toEqual([ 'doctor', 'new', + 'dev', ]); }); @@ -21,6 +22,7 @@ describe('StellarForge root command', () => { ); expect(help).toContain('doctor'); expect(help).toContain('new'); + expect(help).toContain('dev'); expect(help).toContain('-V, --version'); expect(help).toContain('-h, --help'); });