@nemus-cli/nemus 0.16.0 → 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/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.0] - 2026-09-09
11
+
12
+ ### Added
13
+
14
+ - **`nemus dev` — multi-repo dev orchestrator.** Start every repo's dev server in
15
+ a workspace at once and stream their output into one terminal with a
16
+ color-coded, aligned per-repo prefix; a single Ctrl-C tears them all down
17
+ cleanly. Each service runs in its own process group and shutdown SIGTERMs the
18
+ group then SIGKILLs stragglers after `--kill-timeout` (default 5s), so child
19
+ process trees (a dev server's own subprocesses) are never orphaned. For each
20
+ repo it runs `--command "<cmd>"` if given, else the first `package.json` script
21
+ that exists (`--script` → `dev` → `develop` → `start` → `serve`) using the
22
+ repo's own package manager (pnpm/yarn/npm from its lockfile); repos with no
23
+ runnable script are skipped with a notice. Flags: `--only <repos>`,
24
+ `--script`, `--command`, `--exit-on-failure`, `--kill-timeout`.
25
+
10
26
  ## [0.16.0] - 2026-09-09
11
27
 
12
28
  ### Added
package/README.md CHANGED
@@ -240,9 +240,42 @@ nemus status my-workspace # (st) git status for every repo
240
240
  nemus sync my-workspace # (s) git pull every repo (auto-retries on flaky network)
241
241
  nemus diff my-workspace # (d) combined diff summary (--full for raw diffs)
242
242
  nemus run my-workspace "npm install" # (r) run a command in every repo
243
+ nemus dev my-workspace # start every repo's dev server together (one Ctrl-C stops all)
243
244
  nemus doctor my-workspace # (doc) health checks + score
244
245
  ```
245
246
 
247
+ ### `nemus dev` — run all your services together
248
+
249
+ A workspace is usually a set of services you run *together*. `nemus dev` starts
250
+ each repo's dev server at once and streams their output into one terminal with a
251
+ color-coded, aligned per-repo prefix; a single Ctrl-C tears them all down cleanly
252
+ (process-group kill, so child trees die too).
253
+
254
+ ```bash
255
+ nemus dev # start every runnable repo in the current workspace
256
+ nemus dev payments --only web,api # just a subset
257
+ nemus dev payments --script start # prefer a specific npm script
258
+ nemus dev payments --command "make run" # run an exact command in every repo
259
+ nemus dev payments --exit-on-failure # stop everything if any service crashes
260
+ ```
261
+
262
+ ```
263
+ web | VITE ready in 412 ms
264
+ api | listening on :4000
265
+ worker | [queue] connected
266
+ ```
267
+
268
+ For each repo it runs `--command` if given, else the first `package.json` script
269
+ that exists (`--script` → `dev` → `develop` → `start` → `serve`), using the
270
+ repo's own package manager (pnpm/yarn/npm from its lockfile). Repos with no
271
+ runnable script are skipped with a notice, so a library in the workspace won't
272
+ block the services.
273
+
274
+ > On **macOS/Linux** each service runs in its own process group, so shutdown
275
+ > reliably takes down the whole child tree. On **Windows** there are no POSIX
276
+ > process groups; teardown falls back to `taskkill /T /F` (force-kill the tree,
277
+ > no graceful SIGTERM phase).
278
+
246
279
  ```
247
280
  Repo Branch Status Ahead/Behind Modified
248
281
  ─────────────────────────────────────────────────────────────────────
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.registerDevCommand = registerDevCommand;
37
+ const path = __importStar(require("path"));
38
+ const config_1 = require("../utils/config");
39
+ const workspace_meta_1 = require("../utils/workspace-meta");
40
+ const command_helpers_1 = require("../utils/command-helpers");
41
+ const dev_orchestrator_1 = require("../utils/dev-orchestrator");
42
+ const logger_1 = require("../utils/logger");
43
+ const colors_1 = require("../utils/colors");
44
+ function registerDevCommand(parent) {
45
+ parent
46
+ .command('dev [workspace]')
47
+ .description('Start every repo\'s dev server together, with unified color-coded logs (Ctrl-C stops all)')
48
+ .option('--only <repos>', 'Comma-separated subset of repos to start')
49
+ .option('--script <name>', 'npm script to prefer (default: dev → develop → start → serve)')
50
+ .option('--command <cmd>', 'Run this exact command in every selected repo instead of a script')
51
+ .option('--exit-on-failure', 'Tear everything down if any service exits non-zero')
52
+ .option('--kill-timeout <seconds>', 'Grace period before SIGKILL on shutdown', '5')
53
+ .action(async (workspace, opts, cmd) => {
54
+ const globalOpts = (0, command_helpers_1.getGlobalOpts)(cmd);
55
+ await handleDev({ workspace, ...opts, ...globalOpts });
56
+ });
57
+ }
58
+ async function handleDev(opts) {
59
+ try {
60
+ const workspaceName = await (0, command_helpers_1.resolveWorkspace)(opts.workspace);
61
+ const workspacePath = path.join(config_1.WORKSPACES_DIR, workspaceName);
62
+ const metadata = await (0, workspace_meta_1.loadMetadata)(workspacePath);
63
+ if (!metadata) {
64
+ (0, logger_1.logError)(`Workspace not found: ${workspaceName}`);
65
+ process.exit(1);
66
+ }
67
+ let repos = metadata.repositories.filter(r => r.status === 'success');
68
+ if (opts.only) {
69
+ const wanted = new Set((0, command_helpers_1.parseList)(opts.only));
70
+ repos = repos.filter(r => wanted.has(r.directoryName) || wanted.has(r.name));
71
+ if (repos.length === 0) {
72
+ (0, logger_1.logError)(`No repos in "${workspaceName}" matched --only ${opts.only}`);
73
+ process.exit(1);
74
+ }
75
+ }
76
+ const services = [];
77
+ const skipped = [];
78
+ for (const repo of repos) {
79
+ const cwd = path.join(workspacePath, repo.directoryName);
80
+ const command = (0, dev_orchestrator_1.resolveDevCommand)(cwd, { commandOverride: opts.command, script: opts.script });
81
+ if (!command) {
82
+ skipped.push(repo.directoryName);
83
+ continue;
84
+ }
85
+ services.push({ label: repo.directoryName, cwd, command });
86
+ }
87
+ if (services.length === 0) {
88
+ (0, logger_1.logError)(`Nothing to run in "${workspaceName}".`);
89
+ (0, logger_1.logInfo)(opts.script
90
+ ? `No repo has a "${opts.script}" script.`
91
+ : 'No repo has a dev/develop/start/serve script. Pass --command "<cmd>" to run something explicitly.');
92
+ process.exit(1);
93
+ }
94
+ if (skipped.length > 0) {
95
+ (0, logger_1.logWarning)(`Skipped (no runnable script): ${skipped.join(', ')}`);
96
+ }
97
+ console.log('\n' + (0, colors_1.colorize)('Starting dev servers', 'bright') + (0, colors_1.colorize)(` · ${workspaceName}`, 'cyan'));
98
+ for (const s of services) {
99
+ console.log(` ${(0, colors_1.colorize)(s.label, 'cyan')} ${(0, colors_1.colorize)(s.command.command, 'gray')} ${(0, colors_1.colorize)(`(${s.command.source})`, 'gray')}`);
100
+ }
101
+ console.log((0, colors_1.colorize)(' Ctrl-C to stop all.\n', 'gray'));
102
+ // NaN check (not `|| 5`) so an explicit --kill-timeout 0 (immediate SIGKILL)
103
+ // is honored rather than coerced back to the default.
104
+ const parsedTimeout = Number(opts.killTimeout);
105
+ const killTimeoutMs = Math.max(0, Number.isFinite(parsedTimeout) ? parsedTimeout : 5) * 1000;
106
+ const code = await (0, dev_orchestrator_1.runDev)(services, {
107
+ exitOnFailure: opts.exitOnFailure,
108
+ killTimeoutMs,
109
+ });
110
+ process.exit(code);
111
+ }
112
+ catch (error) {
113
+ (0, logger_1.logError)('Failed to start dev servers');
114
+ if (error instanceof Error)
115
+ (0, logger_1.logError)(error.message);
116
+ process.exit(1);
117
+ }
118
+ }
package/dist/program.js CHANGED
@@ -97,6 +97,7 @@ const completion_1 = require("./commands/completion");
97
97
  const reflect_1 = require("./commands/reflect");
98
98
  const lock_1 = require("./commands/lock");
99
99
  const restore_1 = require("./commands/restore");
100
+ const dev_1 = require("./commands/dev");
100
101
  (0, create_1.registerCreateCommand)(exports.program);
101
102
  (0, list_1.registerListCommand)(exports.program);
102
103
  (0, update_1.registerUpdateCommand)(exports.program);
@@ -126,6 +127,7 @@ const restore_1 = require("./commands/restore");
126
127
  (0, reflect_1.registerReflectCommand)(exports.program);
127
128
  (0, lock_1.registerLockCommand)(exports.program);
128
129
  (0, restore_1.registerRestoreCommand)(exports.program);
130
+ (0, dev_1.registerDevCommand)(exports.program);
129
131
  // Register TUI (delegates to existing Ink/React implementation)
130
132
  exports.program
131
133
  .command('tui')
@@ -0,0 +1,324 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.PREFIX_COLORS = exports.DEFAULT_SCRIPT_ORDER = void 0;
37
+ exports.detectPackageManager = detectPackageManager;
38
+ exports.pickDevScript = pickDevScript;
39
+ exports.scriptRunCommand = scriptRunCommand;
40
+ exports.resolveDevCommand = resolveDevCommand;
41
+ exports.assignColors = assignColors;
42
+ exports.formatPrefix = formatPrefix;
43
+ exports.createLineSplitter = createLineSplitter;
44
+ exports.runDev = runDev;
45
+ const child_process_1 = require("child_process");
46
+ const fs = __importStar(require("fs"));
47
+ const path = __importStar(require("path"));
48
+ const colors_1 = require("./colors");
49
+ /** Detect a repo's package manager from its lockfile (defaults to npm). */
50
+ function detectPackageManager(repoPath) {
51
+ if (fs.existsSync(path.join(repoPath, 'pnpm-lock.yaml')))
52
+ return 'pnpm';
53
+ if (fs.existsSync(path.join(repoPath, 'yarn.lock')))
54
+ return 'yarn';
55
+ return 'npm';
56
+ }
57
+ /** Default script preference order when the user doesn't pass --script. */
58
+ exports.DEFAULT_SCRIPT_ORDER = ['dev', 'develop', 'start', 'serve'];
59
+ /**
60
+ * Pick the dev script to run from a package.json `scripts` map. If `preferred`
61
+ * is given and present, it wins; otherwise the first of DEFAULT_SCRIPT_ORDER
62
+ * that exists. Returns null when nothing matches.
63
+ */
64
+ function pickDevScript(scripts, preferred) {
65
+ if (!scripts)
66
+ return null;
67
+ if (preferred)
68
+ return scripts[preferred] ? preferred : null;
69
+ for (const name of exports.DEFAULT_SCRIPT_ORDER) {
70
+ if (scripts[name])
71
+ return name;
72
+ }
73
+ return null;
74
+ }
75
+ /** The command string a package manager uses to run a script. */
76
+ function scriptRunCommand(pm, script) {
77
+ // npm needs `run`; yarn/pnpm accept the bare script name.
78
+ return pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`;
79
+ }
80
+ /**
81
+ * Resolve the command to run for a repo. An explicit `commandOverride` always
82
+ * wins; otherwise we read package.json and pick a script. Returns null when the
83
+ * repo has nothing runnable (e.g. a library with no dev script).
84
+ */
85
+ function resolveDevCommand(repoPath, opts = {}) {
86
+ if (opts.commandOverride) {
87
+ return { command: opts.commandOverride, source: 'command' };
88
+ }
89
+ const pkgPath = path.join(repoPath, 'package.json');
90
+ if (!fs.existsSync(pkgPath))
91
+ return null;
92
+ let scripts;
93
+ try {
94
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
95
+ scripts = pkg?.scripts;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ const script = pickDevScript(scripts, opts.script);
101
+ if (!script)
102
+ return null;
103
+ const pm = detectPackageManager(repoPath);
104
+ return { command: scriptRunCommand(pm, script), source: `${pm} · ${script}` };
105
+ }
106
+ /** Palette used to color per-repo prefixes (cycled if there are more repos). */
107
+ exports.PREFIX_COLORS = [
108
+ 'cyan', 'green', 'yellow', 'magenta', 'blue', 'red', 'white',
109
+ ];
110
+ function assignColors(names) {
111
+ const map = new Map();
112
+ names.forEach((name, i) => map.set(name, exports.PREFIX_COLORS[i % exports.PREFIX_COLORS.length]));
113
+ return map;
114
+ }
115
+ /** Build the aligned, colored `"label | "` prefix for a service's output. */
116
+ function formatPrefix(label, width, color) {
117
+ return (0, colors_1.colorize)(`${label.padEnd(width)} ${(0, colors_1.colorize)('|', 'gray')}`, color);
118
+ }
119
+ /**
120
+ * Stateful line splitter: feed it chunks, it returns complete lines and holds
121
+ * the trailing partial until the next chunk. `flush()` yields any remainder.
122
+ */
123
+ function createLineSplitter() {
124
+ let buffer = '';
125
+ return {
126
+ push(chunk) {
127
+ buffer += chunk;
128
+ const lines = buffer.split('\n');
129
+ buffer = lines.pop() ?? '';
130
+ return lines;
131
+ },
132
+ flush() {
133
+ if (buffer.length === 0)
134
+ return null;
135
+ const rest = buffer;
136
+ buffer = '';
137
+ return rest;
138
+ },
139
+ };
140
+ }
141
+ /**
142
+ * Start every service, multiplex their output with colored prefixes, and drive a
143
+ * clean shutdown on Ctrl-C. Resolves with the exit code to use (first non-zero
144
+ * child code, or 0) once all services have exited.
145
+ */
146
+ function runDev(services, opts = {}) {
147
+ const out = opts.stdout ?? process.stdout;
148
+ const err = opts.stderr ?? process.stderr;
149
+ const spawnFn = opts.spawnFn ?? child_process_1.spawn;
150
+ const killTimeoutMs = opts.killTimeoutMs ?? 5000;
151
+ const kill = opts.killFn ?? killGroup;
152
+ const width = Math.max(...services.map(s => s.label.length), 1);
153
+ const colors = assignColors(services.map(s => s.label));
154
+ return new Promise((resolve) => {
155
+ const running = [];
156
+ let shuttingDown = false;
157
+ let finished = false;
158
+ let firstFailureCode = 0;
159
+ let killTimer = null;
160
+ let disposeSignals = () => { };
161
+ const writePrefixed = (sink, label, text) => {
162
+ const prefix = formatPrefix(label, width, colors.get(label) ?? 'white');
163
+ sink.write(`${prefix} ${text}\n`);
164
+ };
165
+ // SIGKILL every started service's process GROUP. Unconditional by design: a
166
+ // detached leader (the shell) can exit on SIGTERM while its group still has
167
+ // living members (e.g. a grandchild that ignores SIGTERM) — gating on the
168
+ // leader having exited is exactly what leaks orphans. kill(-pid) on an empty
169
+ // group is a harmless ESRCH.
170
+ //
171
+ // Deliberate scope: the sweep only runs as part of shutdown (Ctrl-C, or
172
+ // --exit-on-failure). A single service that exits on its own while others
173
+ // keep running is NOT swept — its stray detached grandchildren (if any) are
174
+ // reaped at the eventual overall shutdown, not immediately. Sweeping a live
175
+ // run per-exit would risk killing an unrelated process that reused the pgid.
176
+ const sigkillSweep = () => {
177
+ for (const r of running)
178
+ kill(r.child, 'SIGKILL');
179
+ };
180
+ const finalize = () => {
181
+ if (finished)
182
+ return;
183
+ finished = true;
184
+ if (killTimer)
185
+ clearTimeout(killTimer);
186
+ disposeSignals();
187
+ resolve(firstFailureCode);
188
+ };
189
+ const maybeFinish = () => {
190
+ if (!running.every(r => r.exited))
191
+ return;
192
+ // All direct children are gone. If we were shutting down, force-reap any
193
+ // orphaned group members (detached grandchildren) before finishing —
194
+ // otherwise we'd exit and leave them running.
195
+ if (shuttingDown)
196
+ sigkillSweep();
197
+ finalize();
198
+ };
199
+ const shutdown = (reason) => {
200
+ if (shuttingDown) {
201
+ // Second Ctrl-C: escalate immediately.
202
+ sigkillSweep();
203
+ return;
204
+ }
205
+ shuttingDown = true;
206
+ err.write(`\n${(0, colors_1.colorize)(`▸ ${reason} — stopping ${running.filter(r => !r.exited).length} service(s)…`, 'yellow')}\n`);
207
+ for (const r of running)
208
+ kill(r.child, 'SIGTERM');
209
+ // Backstop: SIGKILL anything still alive after the grace period, then finish.
210
+ killTimer = setTimeout(() => {
211
+ sigkillSweep();
212
+ finalize();
213
+ }, killTimeoutMs);
214
+ if (typeof killTimer.unref === 'function')
215
+ killTimer.unref();
216
+ };
217
+ disposeSignals = (opts.onSignal ?? defaultOnSignal)((sig) => shutdown(`received ${sig}`));
218
+ for (const service of services) {
219
+ const child = spawnFn(service.command.command, {
220
+ cwd: service.cwd,
221
+ shell: true,
222
+ detached: true,
223
+ stdio: ['ignore', 'pipe', 'pipe'],
224
+ env: process.env,
225
+ });
226
+ const rec = { service, child, exited: false, exitCode: null };
227
+ running.push(rec);
228
+ const outSplitter = createLineSplitter();
229
+ const errSplitter = createLineSplitter();
230
+ child.stdout?.setEncoding('utf-8');
231
+ child.stderr?.setEncoding('utf-8');
232
+ child.stdout?.on('data', (chunk) => {
233
+ for (const line of outSplitter.push(chunk))
234
+ writePrefixed(out, service.label, line);
235
+ });
236
+ child.stderr?.on('data', (chunk) => {
237
+ for (const line of errSplitter.push(chunk))
238
+ writePrefixed(err, service.label, line);
239
+ });
240
+ child.on('error', (e) => {
241
+ if (rec.exited)
242
+ return; // 'exit' already handled this service
243
+ writePrefixed(err, service.label, (0, colors_1.colorize)(`failed to start: ${e.message}`, 'red'));
244
+ rec.exited = true;
245
+ rec.exitCode = 1;
246
+ if (firstFailureCode === 0)
247
+ firstFailureCode = 1;
248
+ maybeFinish();
249
+ });
250
+ child.on('exit', (code, signal) => {
251
+ if (rec.exited)
252
+ return; // 'error' already handled this service
253
+ for (const line of [outSplitter.flush(), errSplitter.flush()]) {
254
+ if (line)
255
+ writePrefixed(out, service.label, line);
256
+ }
257
+ rec.exited = true;
258
+ rec.exitCode = code ?? (signal ? 0 : 1);
259
+ const desc = signal ? `signal ${signal}` : `code ${code}`;
260
+ const color = code && code !== 0 ? 'red' : 'gray';
261
+ err.write(`${(0, colors_1.colorize)(`▸ ${service.label} exited (${desc})`, color)}\n`);
262
+ if (code && code !== 0 && firstFailureCode === 0)
263
+ firstFailureCode = code;
264
+ if (!shuttingDown && opts.exitOnFailure && code && code !== 0) {
265
+ shutdown(`${service.label} failed`);
266
+ }
267
+ maybeFinish();
268
+ });
269
+ }
270
+ if (running.length === 0)
271
+ finalize();
272
+ });
273
+ }
274
+ /**
275
+ * Kill a detached child's whole process tree.
276
+ *
277
+ * POSIX: the child is its own process-group leader (spawned detached), so
278
+ * `kill(-pid)` signals the entire group — the reliable way to take down
279
+ * shell→pm→server→… trees. Windows has no process groups or POSIX signals, so
280
+ * `kill(-pid)` throws; we fall back to `taskkill /T /F` to force-kill the tree
281
+ * (graceful SIGTERM isn't meaningful for a Windows console child tree). If even
282
+ * that isn't available we degrade to a single `child.kill()` (leaf only).
283
+ */
284
+ function killGroup(child, signal) {
285
+ if (child.pid == null)
286
+ return;
287
+ if (process.platform === 'win32') {
288
+ // spawn() reports failures (e.g. taskkill missing → ENOENT) via an async
289
+ // 'error' event, not a synchronous throw, so a try/catch can't catch it — and
290
+ // an unhandled 'error' event would crash the process. Attach a handler that
291
+ // degrades to a leaf-only kill.
292
+ const tk = (0, child_process_1.spawn)('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
293
+ tk.on('error', () => {
294
+ try {
295
+ child.kill();
296
+ }
297
+ catch {
298
+ // already gone
299
+ }
300
+ });
301
+ return;
302
+ }
303
+ try {
304
+ process.kill(-child.pid, signal);
305
+ }
306
+ catch {
307
+ try {
308
+ child.kill(signal);
309
+ }
310
+ catch {
311
+ // already gone
312
+ }
313
+ }
314
+ }
315
+ function defaultOnSignal(handler) {
316
+ const onSigint = () => handler('SIGINT');
317
+ const onSigterm = () => handler('SIGTERM');
318
+ process.on('SIGINT', onSigint);
319
+ process.on('SIGTERM', onSigterm);
320
+ return () => {
321
+ process.off('SIGINT', onSigint);
322
+ process.off('SIGTERM', onSigterm);
323
+ };
324
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nemus-cli/nemus",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "workspaces": [
5
5
  "packages/*"
6
6
  ],
@@ -46,6 +46,7 @@ Global flags: `-f/--force-refresh` (skip repo cache), `-y/--yes` (skip prompts),
46
46
  | Pull latest (git sync) | [workspace-sync](references/workspace-sync.md) | `nemus sync [name]` | `s` |
47
47
  | Show diff summary | [workspace-diff](references/workspace-diff.md) | `nemus diff [name]` | `di` |
48
48
  | Run shell command across repos | [run-command](references/run-command.md) | `nemus run [name] <cmd>` | `r` |
49
+ | Start all repos' dev servers together | [dev](references/dev.md) | `nemus dev [name]` | — |
49
50
  | Health check | [workspace-doctor](references/workspace-doctor.md) | `nemus doctor [name]` | `doc` |
50
51
  | Clean node_modules / artifacts | [workspace-cleanup](references/workspace-cleanup.md) | `nemus cleanup <name>` | `cl` |
51
52
  | Remove repo from workspace | [remove-repo](references/remove-repo.md) | `nemus remove-repo` | `rr` |
@@ -0,0 +1,46 @@
1
+ # dev
2
+
3
+ Start every repo's dev server in a workspace at once, streaming their output into
4
+ one terminal with a color-coded, aligned per-repo prefix. A single Ctrl-C stops
5
+ them all cleanly (process-group kill, so child trees die too).
6
+
7
+ ## CLI
8
+
9
+ ```bash
10
+ nemus dev [workspace] [options]
11
+ ```
12
+
13
+ - `[workspace]` — workspace name; omitted, resolves the current/default one.
14
+ - `--only <repos>` — comma-separated subset of repos to start.
15
+ - `--script <name>` — npm script to prefer (default: `dev` → `develop` → `start` → `serve`).
16
+ - `--command "<cmd>"` — run this exact command in every selected repo instead of a script.
17
+ - `--exit-on-failure` — tear everything down if any service exits non-zero.
18
+ - `--kill-timeout <seconds>` — grace period before SIGKILL on shutdown (default 5).
19
+
20
+ ## Command selection (per repo)
21
+
22
+ 1. `--command` if given.
23
+ 2. Else the first `package.json` script that exists (`--script` → `dev` →
24
+ `develop` → `start` → `serve`), run with the repo's own package manager
25
+ (pnpm/yarn/npm, detected from its lockfile).
26
+ 3. A repo with no runnable script is **skipped with a notice** — so a library in
27
+ the workspace doesn't block the services. If nothing is runnable, `dev` errors.
28
+
29
+ ## Examples
30
+
31
+ ```bash
32
+ nemus dev # every runnable repo in the current workspace
33
+ nemus dev payments --only web,api # just a subset
34
+ nemus dev payments --command "make run"
35
+ nemus dev payments --exit-on-failure
36
+ ```
37
+
38
+ ## Notes
39
+
40
+ - Long-running: it stays in the foreground hosting the servers until they exit or
41
+ you Ctrl-C. Not for scripting — use `nemus run` for one-shot commands.
42
+ - Each service runs in its own process group; shutdown SIGTERMs the group, then
43
+ SIGKILLs stragglers after `--kill-timeout`, so nothing is orphaned.
44
+ - **Platform:** clean process-group teardown is POSIX (macOS/Linux). On Windows
45
+ it falls back to `taskkill /T /F` (force-kill the tree, no graceful phase).
46
+ - `--kill-timeout 0` is honored (immediate SIGKILL after the SIGTERM).
@@ -0,0 +1,98 @@
1
+ import { Command } from 'commander';
2
+ import * as path from 'path';
3
+ import { WORKSPACES_DIR } from '../utils/config';
4
+ import { loadMetadata } from '../utils/workspace-meta';
5
+ import { resolveWorkspace, getGlobalOpts, parseList } from '../utils/command-helpers';
6
+ import { resolveDevCommand, runDev, type DevService } from '../utils/dev-orchestrator';
7
+ import { logError, logInfo, logWarning } from '../utils/logger';
8
+ import { colorize } from '../utils/colors';
9
+
10
+ export function registerDevCommand(parent: Command) {
11
+ parent
12
+ .command('dev [workspace]')
13
+ .description('Start every repo\'s dev server together, with unified color-coded logs (Ctrl-C stops all)')
14
+ .option('--only <repos>', 'Comma-separated subset of repos to start')
15
+ .option('--script <name>', 'npm script to prefer (default: dev → develop → start → serve)')
16
+ .option('--command <cmd>', 'Run this exact command in every selected repo instead of a script')
17
+ .option('--exit-on-failure', 'Tear everything down if any service exits non-zero')
18
+ .option('--kill-timeout <seconds>', 'Grace period before SIGKILL on shutdown', '5')
19
+ .action(async (workspace, opts, cmd) => {
20
+ const globalOpts = getGlobalOpts(cmd);
21
+ await handleDev({ workspace, ...opts, ...globalOpts });
22
+ });
23
+ }
24
+
25
+ async function handleDev(opts: {
26
+ workspace?: string;
27
+ only?: string;
28
+ script?: string;
29
+ command?: string;
30
+ exitOnFailure?: boolean;
31
+ killTimeout?: string;
32
+ }) {
33
+ try {
34
+ const workspaceName = await resolveWorkspace(opts.workspace);
35
+ const workspacePath = path.join(WORKSPACES_DIR, workspaceName);
36
+
37
+ const metadata = await loadMetadata(workspacePath);
38
+ if (!metadata) {
39
+ logError(`Workspace not found: ${workspaceName}`);
40
+ process.exit(1);
41
+ }
42
+
43
+ let repos = metadata.repositories.filter(r => r.status === 'success');
44
+
45
+ if (opts.only) {
46
+ const wanted = new Set(parseList(opts.only));
47
+ repos = repos.filter(r => wanted.has(r.directoryName) || wanted.has(r.name));
48
+ if (repos.length === 0) {
49
+ logError(`No repos in "${workspaceName}" matched --only ${opts.only}`);
50
+ process.exit(1);
51
+ }
52
+ }
53
+
54
+ const services: DevService[] = [];
55
+ const skipped: string[] = [];
56
+ for (const repo of repos) {
57
+ const cwd = path.join(workspacePath, repo.directoryName);
58
+ const command = resolveDevCommand(cwd, { commandOverride: opts.command, script: opts.script });
59
+ if (!command) {
60
+ skipped.push(repo.directoryName);
61
+ continue;
62
+ }
63
+ services.push({ label: repo.directoryName, cwd, command });
64
+ }
65
+
66
+ if (services.length === 0) {
67
+ logError(`Nothing to run in "${workspaceName}".`);
68
+ logInfo(opts.script
69
+ ? `No repo has a "${opts.script}" script.`
70
+ : 'No repo has a dev/develop/start/serve script. Pass --command "<cmd>" to run something explicitly.');
71
+ process.exit(1);
72
+ }
73
+
74
+ if (skipped.length > 0) {
75
+ logWarning(`Skipped (no runnable script): ${skipped.join(', ')}`);
76
+ }
77
+
78
+ console.log('\n' + colorize('Starting dev servers', 'bright') + colorize(` · ${workspaceName}`, 'cyan'));
79
+ for (const s of services) {
80
+ console.log(` ${colorize(s.label, 'cyan')} ${colorize(s.command.command, 'gray')} ${colorize(`(${s.command.source})`, 'gray')}`);
81
+ }
82
+ console.log(colorize(' Ctrl-C to stop all.\n', 'gray'));
83
+
84
+ // NaN check (not `|| 5`) so an explicit --kill-timeout 0 (immediate SIGKILL)
85
+ // is honored rather than coerced back to the default.
86
+ const parsedTimeout = Number(opts.killTimeout);
87
+ const killTimeoutMs = Math.max(0, Number.isFinite(parsedTimeout) ? parsedTimeout : 5) * 1000;
88
+ const code = await runDev(services, {
89
+ exitOnFailure: opts.exitOnFailure,
90
+ killTimeoutMs,
91
+ });
92
+ process.exit(code);
93
+ } catch (error) {
94
+ logError('Failed to start dev servers');
95
+ if (error instanceof Error) logError(error.message);
96
+ process.exit(1);
97
+ }
98
+ }
package/src/program.ts CHANGED
@@ -66,6 +66,7 @@ import { registerCompletionCommand } from './commands/completion';
66
66
  import { registerReflectCommand } from './commands/reflect';
67
67
  import { registerLockCommand } from './commands/lock';
68
68
  import { registerRestoreCommand } from './commands/restore';
69
+ import { registerDevCommand } from './commands/dev';
69
70
 
70
71
  registerCreateCommand(program);
71
72
  registerListCommand(program);
@@ -96,6 +97,7 @@ registerCompletionCommand(program);
96
97
  registerReflectCommand(program);
97
98
  registerLockCommand(program);
98
99
  registerRestoreCommand(program);
100
+ registerDevCommand(program);
99
101
 
100
102
  // Register TUI (delegates to existing Ink/React implementation)
101
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
+ }