@nemus-cli/nemus 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp/tools.ts CHANGED
@@ -21,6 +21,8 @@ import { removeNodeModules, removeBuildArtifacts } from '../utils/cleanup-operat
21
21
  import { runPostCloneHooks } from '../utils/hooks';
22
22
  import { SuiteEntry, WorkspaceSuite, SuitesStore } from '../types';
23
23
  import { resolveWorkspaceNameConflict, sanitizeWorkspaceName, safeWorkspacePath } from '../utils/validation';
24
+ import { buildLock, writeLock, readLockFile, parseLock, LOCK_FILENAME } from '../utils/workspace-lock';
25
+ import { restoreWorkspace } from '../commands/restore';
24
26
 
25
27
  /**
26
28
  * Redirects stdout to stderr for the duration of a function call.
@@ -1017,3 +1019,70 @@ export async function handleSaveContext(workspace: string, content: string, appe
1017
1019
  };
1018
1020
  });
1019
1021
  }
1022
+
1023
+ // ── Portable workspaces: lock / restore ─────────────────────────────────────
1024
+
1025
+ /**
1026
+ * Snapshot a workspace into a nemus.lock manifest (repos + branch + commit).
1027
+ * Writes it to the workspace root by default and also returns the manifest so
1028
+ * an agent can share/commit it.
1029
+ */
1030
+ export async function handleLockWorkspace(workspace: string, output?: string) {
1031
+ return withStdoutProtection(async () => {
1032
+ if (!workspace || workspace.trim().length === 0) {
1033
+ throw new Error('Workspace name is required');
1034
+ }
1035
+ const workspacePath = safeWorkspacePath(sanitizeWorkspaceName(workspace));
1036
+ const metadata = await loadMetadata(workspacePath);
1037
+ if (!metadata) {
1038
+ throw new Error(`Workspace not found: ${workspace}`);
1039
+ }
1040
+ const lock = await buildLock(workspacePath, metadata);
1041
+ const outPath = output ? path.resolve(output) : path.join(workspacePath, LOCK_FILENAME);
1042
+ await writeLock(outPath, lock);
1043
+ return {
1044
+ workspace: metadata.workspaceName,
1045
+ lockfilePath: outPath,
1046
+ repoCount: lock.repositories.length,
1047
+ lock,
1048
+ };
1049
+ });
1050
+ }
1051
+
1052
+ /**
1053
+ * Recreate a workspace from a nemus.lock. Accepts either inline `lockContent`
1054
+ * (the manifest JSON) or a `lockfile` path (defaults to ./nemus.lock). Clones
1055
+ * every repo and checks out the recorded branch (or exact commit with `pin`).
1056
+ */
1057
+ export async function handleRestoreWorkspace(opts: {
1058
+ workspace?: string;
1059
+ lockfile?: string;
1060
+ lockContent?: string;
1061
+ pin?: boolean;
1062
+ }) {
1063
+ return withStdoutProtection(async () => {
1064
+ const lock = opts.lockContent
1065
+ ? parseLock(opts.lockContent)
1066
+ : await readLockFile(path.resolve(opts.lockfile || LOCK_FILENAME));
1067
+
1068
+ const { workspaceName, workspacePath, results } = await restoreWorkspace(lock, {
1069
+ workspace: opts.workspace,
1070
+ pin: opts.pin,
1071
+ });
1072
+
1073
+ const cloned = results.filter(r => r.status === 'success');
1074
+ const failed = results.filter(r => r.status === 'failed');
1075
+ return {
1076
+ workspace: workspaceName,
1077
+ path: workspacePath,
1078
+ cloned: cloned.length,
1079
+ failed: failed.length,
1080
+ repositories: results.map(r => ({
1081
+ name: r.repo.name,
1082
+ directoryName: r.directoryName,
1083
+ status: r.status,
1084
+ ...(r.error ? { error: r.error } : {}),
1085
+ })),
1086
+ };
1087
+ });
1088
+ }
package/src/program.ts CHANGED
@@ -64,6 +64,9 @@ import { registerMigrateCommand } from './commands/migrate';
64
64
  import { registerReportBugCommand } from './commands/report-bug';
65
65
  import { registerCompletionCommand } from './commands/completion';
66
66
  import { registerReflectCommand } from './commands/reflect';
67
+ import { registerLockCommand } from './commands/lock';
68
+ import { registerRestoreCommand } from './commands/restore';
69
+ import { registerDevCommand } from './commands/dev';
67
70
 
68
71
  registerCreateCommand(program);
69
72
  registerListCommand(program);
@@ -92,6 +95,9 @@ registerMigrateCommand(program);
92
95
  registerReportBugCommand(program);
93
96
  registerCompletionCommand(program);
94
97
  registerReflectCommand(program);
98
+ registerLockCommand(program);
99
+ registerRestoreCommand(program);
100
+ registerDevCommand(program);
95
101
 
96
102
  // Register TUI (delegates to existing Ink/React implementation)
97
103
  program
@@ -0,0 +1,207 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { EventEmitter } from 'events';
3
+ import { PassThrough } from 'stream';
4
+ import type { ChildProcess } from 'child_process';
5
+ import * as fs from 'fs';
6
+ import * as os from 'os';
7
+ import * as path from 'path';
8
+ import { setColorEnabled } from './colors';
9
+ import {
10
+ detectPackageManager,
11
+ pickDevScript,
12
+ scriptRunCommand,
13
+ resolveDevCommand,
14
+ assignColors,
15
+ formatPrefix,
16
+ createLineSplitter,
17
+ runDev,
18
+ PREFIX_COLORS,
19
+ type DevService,
20
+ } from './dev-orchestrator';
21
+
22
+ beforeEach(() => setColorEnabled(false)); // deterministic, uncolored strings
23
+
24
+ describe('detectPackageManager', () => {
25
+ let tmp: string;
26
+ beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dev-pm-')); });
27
+ afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
28
+
29
+ it('detects pnpm/yarn from lockfiles, defaults to npm', () => {
30
+ expect(detectPackageManager(tmp)).toBe('npm');
31
+ fs.writeFileSync(path.join(tmp, 'yarn.lock'), '');
32
+ expect(detectPackageManager(tmp)).toBe('yarn');
33
+ fs.writeFileSync(path.join(tmp, 'pnpm-lock.yaml'), '');
34
+ expect(detectPackageManager(tmp)).toBe('pnpm'); // pnpm wins over yarn
35
+ });
36
+ });
37
+
38
+ describe('pickDevScript', () => {
39
+ it('honors an explicit preferred script when present', () => {
40
+ expect(pickDevScript({ dev: 'x', start: 'y' }, 'start')).toBe('start');
41
+ expect(pickDevScript({ dev: 'x' }, 'start')).toBeNull(); // preferred missing
42
+ });
43
+ it('falls back to dev → develop → start → serve order', () => {
44
+ expect(pickDevScript({ start: 'a', serve: 'b' })).toBe('start');
45
+ expect(pickDevScript({ serve: 'b' })).toBe('serve');
46
+ expect(pickDevScript({ develop: 'd', start: 's' })).toBe('develop');
47
+ });
48
+ it('returns null for no scripts / no match', () => {
49
+ expect(pickDevScript(undefined)).toBeNull();
50
+ expect(pickDevScript({ build: 'x', test: 'y' })).toBeNull();
51
+ });
52
+ });
53
+
54
+ describe('scriptRunCommand', () => {
55
+ it('uses run for npm, bare for yarn/pnpm', () => {
56
+ expect(scriptRunCommand('npm', 'dev')).toBe('npm run dev');
57
+ expect(scriptRunCommand('yarn', 'dev')).toBe('yarn dev');
58
+ expect(scriptRunCommand('pnpm', 'start')).toBe('pnpm start');
59
+ });
60
+ });
61
+
62
+ describe('resolveDevCommand', () => {
63
+ let tmp: string;
64
+ beforeEach(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dev-res-')); });
65
+ afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
66
+
67
+ it('prefers an explicit command override', () => {
68
+ expect(resolveDevCommand(tmp, { commandOverride: 'make run' })).toEqual({ command: 'make run', source: 'command' });
69
+ });
70
+ it('resolves a package.json script with the detected pm', () => {
71
+ fs.writeFileSync(path.join(tmp, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
72
+ fs.writeFileSync(path.join(tmp, 'pnpm-lock.yaml'), '');
73
+ expect(resolveDevCommand(tmp)).toEqual({ command: 'pnpm dev', source: 'pnpm · dev' });
74
+ });
75
+ it('returns null when there is no package.json or no runnable script', () => {
76
+ expect(resolveDevCommand(tmp)).toBeNull();
77
+ fs.writeFileSync(path.join(tmp, 'package.json'), JSON.stringify({ scripts: { build: 'x' } }));
78
+ expect(resolveDevCommand(tmp)).toBeNull();
79
+ });
80
+ it('returns null for malformed package.json', () => {
81
+ fs.writeFileSync(path.join(tmp, 'package.json'), '{ not json');
82
+ expect(resolveDevCommand(tmp)).toBeNull();
83
+ });
84
+ });
85
+
86
+ describe('assignColors', () => {
87
+ it('assigns and cycles the palette', () => {
88
+ const many = Array.from({ length: PREFIX_COLORS.length + 2 }, (_, i) => `r${i}`);
89
+ const map = assignColors(many);
90
+ expect(map.get('r0')).toBe(PREFIX_COLORS[0]);
91
+ expect(map.get(`r${PREFIX_COLORS.length}`)).toBe(PREFIX_COLORS[0]); // wrapped
92
+ });
93
+ });
94
+
95
+ describe('formatPrefix', () => {
96
+ it('pads the label to the column width (colors disabled)', () => {
97
+ expect(formatPrefix('web', 6, 'cyan')).toBe('web |');
98
+ });
99
+ });
100
+
101
+ describe('createLineSplitter', () => {
102
+ it('emits complete lines and holds the partial until flushed', () => {
103
+ const s = createLineSplitter();
104
+ expect(s.push('hello\nwor')).toEqual(['hello']);
105
+ expect(s.push('ld\n')).toEqual(['world']);
106
+ expect(s.push('tail')).toEqual([]);
107
+ expect(s.flush()).toBe('tail');
108
+ expect(s.flush()).toBeNull();
109
+ });
110
+ });
111
+
112
+ // ── runDev integration (fake spawn / signals / kills) ───────────────────────
113
+
114
+ class FakeChild extends EventEmitter {
115
+ stdout = new PassThrough();
116
+ stderr = new PassThrough();
117
+ pid = Math.floor(Math.random() * 1e6);
118
+ kill() { return true; }
119
+ emitExit(code: number | null, signal: NodeJS.Signals | null = null) {
120
+ this.emit('exit', code, signal);
121
+ }
122
+ }
123
+
124
+ function harness(labels: string[]) {
125
+ const children = new Map<string, FakeChild>();
126
+ const out = new PassThrough();
127
+ const err = new PassThrough();
128
+ let outBuf = ''; out.on('data', c => (outBuf += c));
129
+ let errBuf = ''; err.on('data', c => (errBuf += c));
130
+ let signalHandler: ((s: NodeJS.Signals) => void) | null = null;
131
+ const kills: Array<{ label: string; signal: string }> = [];
132
+
133
+ const services: DevService[] = labels.map(label => ({
134
+ label, cwd: `/tmp/${label}`, command: { command: `run ${label}`, source: 'command' },
135
+ }));
136
+ const labelByChild = new Map<FakeChild, string>();
137
+
138
+ const opts = {
139
+ stdout: out, stderr: err,
140
+ spawnFn: ((_cmd: string) => {
141
+ // services spawn in order, so map by creation order
142
+ const label = labels[children.size];
143
+ const c = new FakeChild();
144
+ children.set(label, c);
145
+ labelByChild.set(c, label);
146
+ return c as unknown as ChildProcess;
147
+ }) as any,
148
+ onSignal: (h: (s: NodeJS.Signals) => void) => { signalHandler = h; return () => { signalHandler = null; }; },
149
+ killFn: (child: ChildProcess, signal: NodeJS.Signals) => {
150
+ kills.push({ label: labelByChild.get(child as unknown as FakeChild)!, signal });
151
+ },
152
+ };
153
+ return { services, opts, children, kills, getOut: () => outBuf, getErr: () => errBuf, signal: (s: NodeJS.Signals) => signalHandler?.(s) };
154
+ }
155
+
156
+ describe('runDev', () => {
157
+ it('prefixes output and resolves 0 when all services exit cleanly', async () => {
158
+ const h = harness(['web', 'api']);
159
+ const p = runDev(h.services, h.opts);
160
+ h.children.get('web')!.stdout.write('ready on :3000\n');
161
+ h.children.get('api')!.stdout.write('listening\n');
162
+ h.children.get('web')!.emitExit(0);
163
+ h.children.get('api')!.emitExit(0);
164
+ expect(await p).toBe(0);
165
+ expect(h.getOut()).toContain('web | ready on :3000');
166
+ expect(h.getOut()).toContain('api | listening');
167
+ });
168
+
169
+ it('returns the first non-zero exit code', async () => {
170
+ const h = harness(['web']);
171
+ const p = runDev(h.services, h.opts);
172
+ h.children.get('web')!.emitExit(2);
173
+ expect(await p).toBe(2);
174
+ });
175
+
176
+ it('flushes a newline-less trailing line on exit', async () => {
177
+ const h = harness(['web']);
178
+ const p = runDev(h.services, h.opts);
179
+ h.children.get('web')!.stdout.write('no newline here');
180
+ h.children.get('web')!.emitExit(0);
181
+ await p;
182
+ expect(h.getOut()).toContain('web | no newline here');
183
+ });
184
+
185
+ it('on signal: SIGTERMs every group, then SIGKILL-sweeps before finishing', async () => {
186
+ const h = harness(['web', 'api']);
187
+ const p = runDev(h.services, { ...h.opts, killTimeoutMs: 10 });
188
+ h.signal('SIGINT');
189
+ // both get SIGTERM immediately
190
+ expect(h.kills.filter(k => k.signal === 'SIGTERM').map(k => k.label).sort()).toEqual(['api', 'web']);
191
+ // children exit in response
192
+ h.children.get('web')!.emitExit(0, 'SIGTERM');
193
+ h.children.get('api')!.emitExit(0, 'SIGTERM');
194
+ await p;
195
+ // a SIGKILL sweep runs before finishing (reaps orphaned group members)
196
+ expect(h.kills.some(k => k.signal === 'SIGKILL')).toBe(true);
197
+ });
198
+
199
+ it('exitOnFailure tears everything down when a service fails', async () => {
200
+ const h = harness(['web', 'api']);
201
+ const p = runDev(h.services, { ...h.opts, exitOnFailure: true, killTimeoutMs: 10 });
202
+ h.children.get('web')!.emitExit(1); // failure triggers shutdown
203
+ expect(h.kills.some(k => k.label === 'api' && k.signal === 'SIGTERM')).toBe(true);
204
+ h.children.get('api')!.emitExit(0, 'SIGTERM');
205
+ expect(await p).toBe(1);
206
+ });
207
+ });
@@ -0,0 +1,325 @@
1
+ import { spawn, type ChildProcess } from 'child_process';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { colorize, type ColorName } from './colors';
5
+
6
+ export type PackageManager = 'npm' | 'yarn' | 'pnpm';
7
+
8
+ /** Detect a repo's package manager from its lockfile (defaults to npm). */
9
+ export function detectPackageManager(repoPath: string): PackageManager {
10
+ if (fs.existsSync(path.join(repoPath, 'pnpm-lock.yaml'))) return 'pnpm';
11
+ if (fs.existsSync(path.join(repoPath, 'yarn.lock'))) return 'yarn';
12
+ return 'npm';
13
+ }
14
+
15
+ /** Default script preference order when the user doesn't pass --script. */
16
+ export const DEFAULT_SCRIPT_ORDER = ['dev', 'develop', 'start', 'serve'] as const;
17
+
18
+ /**
19
+ * Pick the dev script to run from a package.json `scripts` map. If `preferred`
20
+ * is given and present, it wins; otherwise the first of DEFAULT_SCRIPT_ORDER
21
+ * that exists. Returns null when nothing matches.
22
+ */
23
+ export function pickDevScript(
24
+ scripts: Record<string, string> | undefined,
25
+ preferred?: string
26
+ ): string | null {
27
+ if (!scripts) return null;
28
+ if (preferred) return scripts[preferred] ? preferred : null;
29
+ for (const name of DEFAULT_SCRIPT_ORDER) {
30
+ if (scripts[name]) return name;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ /** The command string a package manager uses to run a script. */
36
+ export function scriptRunCommand(pm: PackageManager, script: string): string {
37
+ // npm needs `run`; yarn/pnpm accept the bare script name.
38
+ return pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`;
39
+ }
40
+
41
+ export interface DevCommand {
42
+ /** Full shell command string to run in the repo. */
43
+ command: string;
44
+ /** How it was chosen, for the startup banner. */
45
+ source: string;
46
+ }
47
+
48
+ /**
49
+ * Resolve the command to run for a repo. An explicit `commandOverride` always
50
+ * wins; otherwise we read package.json and pick a script. Returns null when the
51
+ * repo has nothing runnable (e.g. a library with no dev script).
52
+ */
53
+ export function resolveDevCommand(
54
+ repoPath: string,
55
+ opts: { commandOverride?: string; script?: string } = {}
56
+ ): DevCommand | null {
57
+ if (opts.commandOverride) {
58
+ return { command: opts.commandOverride, source: 'command' };
59
+ }
60
+
61
+ const pkgPath = path.join(repoPath, 'package.json');
62
+ if (!fs.existsSync(pkgPath)) return null;
63
+
64
+ let scripts: Record<string, string> | undefined;
65
+ try {
66
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
67
+ scripts = pkg?.scripts;
68
+ } catch {
69
+ return null;
70
+ }
71
+
72
+ const script = pickDevScript(scripts, opts.script);
73
+ if (!script) return null;
74
+
75
+ const pm = detectPackageManager(repoPath);
76
+ return { command: scriptRunCommand(pm, script), source: `${pm} · ${script}` };
77
+ }
78
+
79
+ /** Palette used to color per-repo prefixes (cycled if there are more repos). */
80
+ export const PREFIX_COLORS: ColorName[] = [
81
+ 'cyan', 'green', 'yellow', 'magenta', 'blue', 'red', 'white',
82
+ ];
83
+
84
+ export function assignColors(names: string[]): Map<string, ColorName> {
85
+ const map = new Map<string, ColorName>();
86
+ names.forEach((name, i) => map.set(name, PREFIX_COLORS[i % PREFIX_COLORS.length]));
87
+ return map;
88
+ }
89
+
90
+ /** Build the aligned, colored `"label | "` prefix for a service's output. */
91
+ export function formatPrefix(label: string, width: number, color: ColorName): string {
92
+ return colorize(`${label.padEnd(width)} ${colorize('|', 'gray')}`, color);
93
+ }
94
+
95
+ /**
96
+ * Stateful line splitter: feed it chunks, it returns complete lines and holds
97
+ * the trailing partial until the next chunk. `flush()` yields any remainder.
98
+ */
99
+ export function createLineSplitter() {
100
+ let buffer = '';
101
+ return {
102
+ push(chunk: string): string[] {
103
+ buffer += chunk;
104
+ const lines = buffer.split('\n');
105
+ buffer = lines.pop() ?? '';
106
+ return lines;
107
+ },
108
+ flush(): string | null {
109
+ if (buffer.length === 0) return null;
110
+ const rest = buffer;
111
+ buffer = '';
112
+ return rest;
113
+ },
114
+ };
115
+ }
116
+
117
+ export interface DevService {
118
+ label: string;
119
+ cwd: string;
120
+ command: DevCommand;
121
+ }
122
+
123
+ export interface RunDevOptions {
124
+ exitOnFailure?: boolean;
125
+ killTimeoutMs?: number;
126
+ /** Injectable sinks + spawn for testing; default to real stdout/stderr/spawn. */
127
+ stdout?: NodeJS.WritableStream;
128
+ stderr?: NodeJS.WritableStream;
129
+ spawnFn?: typeof spawn;
130
+ /** Register a signal handler; returns a disposer. Injected for tests. */
131
+ onSignal?: (handler: (sig: NodeJS.Signals) => void) => () => void;
132
+ /** How to signal a child's process group. Injected for tests. */
133
+ killFn?: (child: ChildProcess, signal: NodeJS.Signals) => void;
134
+ }
135
+
136
+ interface RunningService {
137
+ service: DevService;
138
+ child: ChildProcess;
139
+ exited: boolean;
140
+ exitCode: number | null;
141
+ }
142
+
143
+ /**
144
+ * Start every service, multiplex their output with colored prefixes, and drive a
145
+ * clean shutdown on Ctrl-C. Resolves with the exit code to use (first non-zero
146
+ * child code, or 0) once all services have exited.
147
+ */
148
+ export function runDev(services: DevService[], opts: RunDevOptions = {}): Promise<number> {
149
+ const out = opts.stdout ?? process.stdout;
150
+ const err = opts.stderr ?? process.stderr;
151
+ const spawnFn = opts.spawnFn ?? spawn;
152
+ const killTimeoutMs = opts.killTimeoutMs ?? 5000;
153
+ const kill = opts.killFn ?? killGroup;
154
+ const width = Math.max(...services.map(s => s.label.length), 1);
155
+ const colors = assignColors(services.map(s => s.label));
156
+
157
+ return new Promise<number>((resolve) => {
158
+ const running: RunningService[] = [];
159
+ let shuttingDown = false;
160
+ let finished = false;
161
+ let firstFailureCode = 0;
162
+ let killTimer: ReturnType<typeof setTimeout> | null = null;
163
+ let disposeSignals: () => void = () => {};
164
+
165
+ const writePrefixed = (
166
+ sink: NodeJS.WritableStream,
167
+ label: string,
168
+ text: string
169
+ ) => {
170
+ const prefix = formatPrefix(label, width, colors.get(label) ?? 'white');
171
+ sink.write(`${prefix} ${text}\n`);
172
+ };
173
+
174
+ // SIGKILL every started service's process GROUP. Unconditional by design: a
175
+ // detached leader (the shell) can exit on SIGTERM while its group still has
176
+ // living members (e.g. a grandchild that ignores SIGTERM) — gating on the
177
+ // leader having exited is exactly what leaks orphans. kill(-pid) on an empty
178
+ // group is a harmless ESRCH.
179
+ //
180
+ // Deliberate scope: the sweep only runs as part of shutdown (Ctrl-C, or
181
+ // --exit-on-failure). A single service that exits on its own while others
182
+ // keep running is NOT swept — its stray detached grandchildren (if any) are
183
+ // reaped at the eventual overall shutdown, not immediately. Sweeping a live
184
+ // run per-exit would risk killing an unrelated process that reused the pgid.
185
+ const sigkillSweep = () => {
186
+ for (const r of running) kill(r.child, 'SIGKILL');
187
+ };
188
+
189
+ const finalize = () => {
190
+ if (finished) return;
191
+ finished = true;
192
+ if (killTimer) clearTimeout(killTimer);
193
+ disposeSignals();
194
+ resolve(firstFailureCode);
195
+ };
196
+
197
+ const maybeFinish = () => {
198
+ if (!running.every(r => r.exited)) return;
199
+ // All direct children are gone. If we were shutting down, force-reap any
200
+ // orphaned group members (detached grandchildren) before finishing —
201
+ // otherwise we'd exit and leave them running.
202
+ if (shuttingDown) sigkillSweep();
203
+ finalize();
204
+ };
205
+
206
+ const shutdown = (reason: string) => {
207
+ if (shuttingDown) {
208
+ // Second Ctrl-C: escalate immediately.
209
+ sigkillSweep();
210
+ return;
211
+ }
212
+ shuttingDown = true;
213
+ err.write(`\n${colorize(`▸ ${reason} — stopping ${running.filter(r => !r.exited).length} service(s)…`, 'yellow')}\n`);
214
+ for (const r of running) kill(r.child, 'SIGTERM');
215
+ // Backstop: SIGKILL anything still alive after the grace period, then finish.
216
+ killTimer = setTimeout(() => {
217
+ sigkillSweep();
218
+ finalize();
219
+ }, killTimeoutMs);
220
+ if (typeof killTimer.unref === 'function') killTimer.unref();
221
+ };
222
+
223
+ disposeSignals = (opts.onSignal ?? defaultOnSignal)((sig) => shutdown(`received ${sig}`));
224
+
225
+ for (const service of services) {
226
+ const child = spawnFn(service.command.command, {
227
+ cwd: service.cwd,
228
+ shell: true,
229
+ detached: true,
230
+ stdio: ['ignore', 'pipe', 'pipe'],
231
+ env: process.env,
232
+ });
233
+ const rec: RunningService = { service, child, exited: false, exitCode: null };
234
+ running.push(rec);
235
+
236
+ const outSplitter = createLineSplitter();
237
+ const errSplitter = createLineSplitter();
238
+ child.stdout?.setEncoding('utf-8');
239
+ child.stderr?.setEncoding('utf-8');
240
+ child.stdout?.on('data', (chunk: string) => {
241
+ for (const line of outSplitter.push(chunk)) writePrefixed(out, service.label, line);
242
+ });
243
+ child.stderr?.on('data', (chunk: string) => {
244
+ for (const line of errSplitter.push(chunk)) writePrefixed(err, service.label, line);
245
+ });
246
+
247
+ child.on('error', (e) => {
248
+ if (rec.exited) return; // 'exit' already handled this service
249
+ writePrefixed(err, service.label, colorize(`failed to start: ${e.message}`, 'red'));
250
+ rec.exited = true;
251
+ rec.exitCode = 1;
252
+ if (firstFailureCode === 0) firstFailureCode = 1;
253
+ maybeFinish();
254
+ });
255
+
256
+ child.on('exit', (code, signal) => {
257
+ if (rec.exited) return; // 'error' already handled this service
258
+ for (const line of [outSplitter.flush(), errSplitter.flush()]) {
259
+ if (line) writePrefixed(out, service.label, line);
260
+ }
261
+ rec.exited = true;
262
+ rec.exitCode = code ?? (signal ? 0 : 1);
263
+ const desc = signal ? `signal ${signal}` : `code ${code}`;
264
+ const color = code && code !== 0 ? 'red' : 'gray';
265
+ err.write(`${colorize(`▸ ${service.label} exited (${desc})`, color)}\n`);
266
+ if (code && code !== 0 && firstFailureCode === 0) firstFailureCode = code;
267
+ if (!shuttingDown && opts.exitOnFailure && code && code !== 0) {
268
+ shutdown(`${service.label} failed`);
269
+ }
270
+ maybeFinish();
271
+ });
272
+ }
273
+
274
+ if (running.length === 0) finalize();
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Kill a detached child's whole process tree.
280
+ *
281
+ * POSIX: the child is its own process-group leader (spawned detached), so
282
+ * `kill(-pid)` signals the entire group — the reliable way to take down
283
+ * shell→pm→server→… trees. Windows has no process groups or POSIX signals, so
284
+ * `kill(-pid)` throws; we fall back to `taskkill /T /F` to force-kill the tree
285
+ * (graceful SIGTERM isn't meaningful for a Windows console child tree). If even
286
+ * that isn't available we degrade to a single `child.kill()` (leaf only).
287
+ */
288
+ function killGroup(child: ChildProcess, signal: NodeJS.Signals): void {
289
+ if (child.pid == null) return;
290
+ if (process.platform === 'win32') {
291
+ // spawn() reports failures (e.g. taskkill missing → ENOENT) via an async
292
+ // 'error' event, not a synchronous throw, so a try/catch can't catch it — and
293
+ // an unhandled 'error' event would crash the process. Attach a handler that
294
+ // degrades to a leaf-only kill.
295
+ const tk = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
296
+ tk.on('error', () => {
297
+ try {
298
+ child.kill();
299
+ } catch {
300
+ // already gone
301
+ }
302
+ });
303
+ return;
304
+ }
305
+ try {
306
+ process.kill(-child.pid, signal);
307
+ } catch {
308
+ try {
309
+ child.kill(signal);
310
+ } catch {
311
+ // already gone
312
+ }
313
+ }
314
+ }
315
+
316
+ function defaultOnSignal(handler: (sig: NodeJS.Signals) => void): () => void {
317
+ const onSigint = () => handler('SIGINT');
318
+ const onSigterm = () => handler('SIGTERM');
319
+ process.on('SIGINT', onSigint);
320
+ process.on('SIGTERM', onSigterm);
321
+ return () => {
322
+ process.off('SIGINT', onSigint);
323
+ process.off('SIGTERM', onSigterm);
324
+ };
325
+ }