@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 +16 -0
- package/README.md +33 -0
- package/dist/commands/dev.js +118 -0
- package/dist/program.js +2 -0
- package/dist/utils/dev-orchestrator.js +324 -0
- package/package.json +1 -1
- package/skills/nemus/SKILL.md +1 -0
- package/skills/nemus/references/dev.md +46 -0
- package/src/commands/dev.ts +98 -0
- package/src/program.ts +2 -0
- package/src/utils/dev-orchestrator.test.ts +207 -0
- package/src/utils/dev-orchestrator.ts +325 -0
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
package/skills/nemus/SKILL.md
CHANGED
|
@@ -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
|
+
}
|