@lenne.tech/cli 1.49.0 → 1.50.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/build/commands/dev/doctor.js +27 -10
- package/build/commands/dev/down.js +48 -5
- package/build/commands/dev/status.js +32 -8
- package/build/commands/dev/up.js +59 -21
- package/build/extensions/server.js +1 -1
- package/build/lib/caddy.js +17 -10
- package/build/lib/check-template-sync.js +1 -1
- package/build/lib/dev-env-bridge.js +7 -0
- package/build/lib/dev-env.js +44 -4
- package/build/lib/dev-log-tail.js +91 -0
- package/build/lib/dev-patches.js +15 -5
- package/build/lib/dev-process.js +362 -127
- package/build/lib/dev-project.js +14 -1
- package/build/lib/dev-prune.js +15 -5
- package/build/lib/dev-service.js +29 -15
- package/build/lib/dev-state.js +40 -4
- package/build/lib/dev-test-session.js +58 -36
- package/build/lib/dev-ticket.js +13 -1
- package/build/lib/hoist-workspace-pnpm-config.js +17 -6
- package/build/lib/platform.js +53 -1
- package/build/templates/nest-server-starter/README.md.ejs +13 -6
- package/package.json +4 -1
package/build/lib/dev-patches.js
CHANGED
|
@@ -125,7 +125,7 @@ function patchApiConfig(file) {
|
|
|
125
125
|
* in place.
|
|
126
126
|
*/
|
|
127
127
|
function patchClaudeMd(file, options) {
|
|
128
|
-
const { dbName, identity } = options;
|
|
128
|
+
const { apiPort, appPort, dbName, identity } = options;
|
|
129
129
|
const startMarker = '<!-- lt-dev:url-block:start -->';
|
|
130
130
|
const endMarker = '<!-- lt-dev:url-block:end -->';
|
|
131
131
|
const apiSub = identity.subdomains.api;
|
|
@@ -150,10 +150,20 @@ function patchClaudeMd(file, options) {
|
|
|
150
150
|
'**Active URLs for THIS project:**',
|
|
151
151
|
'',
|
|
152
152
|
];
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
// Both addresses, with who each one is for. The block tells an agent never to
|
|
154
|
+
// assume `localhost:3000/3001` — so if it only named the `*.localhost` host, an
|
|
155
|
+
// agent following it would `curl` a name that does not resolve for Node on
|
|
156
|
+
// Windows, and fail silently. The browser address stays first: it is the one a
|
|
157
|
+
// human opens.
|
|
158
|
+
if (appSub) {
|
|
159
|
+
lines.push(`- App: \`https://${appSub.hostname}\`${appPort ? ` — from a script: \`http://127.0.0.1:${appPort}\`` : ''}`);
|
|
160
|
+
}
|
|
161
|
+
if (apiSub) {
|
|
162
|
+
lines.push(`- API: \`https://${apiSub.hostname}\`${apiPort ? ` — from a script: \`http://127.0.0.1:${apiPort}\`` : ''}`);
|
|
163
|
+
}
|
|
164
|
+
if (appPort || apiPort) {
|
|
165
|
+
lines.push('- The `*.localhost` names are resolved by BROWSERS. Node, `curl` and other tools may not resolve them (they do not on Windows) — use the loopback address from a script.');
|
|
166
|
+
}
|
|
157
167
|
if (dbName)
|
|
158
168
|
lines.push(`- DB: \`mongodb://127.0.0.1/${dbName}\``);
|
|
159
169
|
lines.push('');
|
package/build/lib/dev-process.js
CHANGED
|
@@ -9,57 +9,37 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
9
9
|
});
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.checkPortInUse = checkPortInUse;
|
|
13
12
|
exports.detachedSpawnCommand = detachedSpawnCommand;
|
|
13
|
+
exports.httpStatus = httpStatus;
|
|
14
|
+
exports.isPortBound = isPortBound;
|
|
14
15
|
exports.killProcessGroup = killProcessGroup;
|
|
15
|
-
exports.
|
|
16
|
+
exports.planTermination = planTermination;
|
|
17
|
+
exports.probePorts = probePorts;
|
|
16
18
|
exports.rotateLogFile = rotateLogFile;
|
|
17
19
|
exports.runChildInherit = runChildInherit;
|
|
18
20
|
exports.runChildToFile = runChildToFile;
|
|
19
21
|
exports.spawnDetached = spawnDetached;
|
|
20
22
|
exports.terminateProcessGroup = terminateProcessGroup;
|
|
21
23
|
exports.waitForHttp = waitForHttp;
|
|
24
|
+
exports.windowsTrampoline = windowsTrampoline;
|
|
22
25
|
/**
|
|
23
26
|
* Process + port helpers for `lt dev`.
|
|
24
27
|
*
|
|
25
28
|
* - `spawnDetached`: detached child whose stdout/stderr go to a log file.
|
|
26
29
|
* The Claude Code session does NOT block waiting for it, and `lt dev down`
|
|
27
|
-
* can SIGTERM the entire process group
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
+
* can SIGTERM the entire process group (`killProcessGroup`, gated by
|
|
31
|
+
* `planTermination`).
|
|
32
|
+
* - `probePorts`: which of a set of ports has a listener (a TCP connect, so it
|
|
33
|
+
* answers on every platform) and — where the platform can say — who holds it.
|
|
30
34
|
*/
|
|
31
35
|
const child_process_1 = require("child_process");
|
|
32
36
|
const fs_1 = require("fs");
|
|
37
|
+
const http_1 = require("http");
|
|
38
|
+
const https_1 = require("https");
|
|
39
|
+
const net_1 = require("net");
|
|
33
40
|
const path_1 = require("path");
|
|
34
41
|
const dev_state_1 = require("./dev-state");
|
|
35
|
-
|
|
36
|
-
* Check via `lsof` whether a single TCP port is bound by a LISTEN socket.
|
|
37
|
-
* Returns null if lsof is unavailable.
|
|
38
|
-
*/
|
|
39
|
-
function checkPortInUse(port) {
|
|
40
|
-
return __awaiter(this, void 0, void 0, function* () {
|
|
41
|
-
return new Promise((resolve) => {
|
|
42
|
-
var _a;
|
|
43
|
-
const child = (0, child_process_1.spawn)('lsof', ['-iTCP', `-sTCP:LISTEN`, '-nP', `-iTCP:${port}`], {
|
|
44
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
45
|
-
});
|
|
46
|
-
let stdout = '';
|
|
47
|
-
let errored = false;
|
|
48
|
-
(_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b)));
|
|
49
|
-
child.on('error', () => (errored = true));
|
|
50
|
-
child.on('close', () => {
|
|
51
|
-
if (errored)
|
|
52
|
-
return resolve(null);
|
|
53
|
-
const lines = stdout.split('\n').filter((l) => l && !l.startsWith('COMMAND'));
|
|
54
|
-
const matching = lines.find((l) => new RegExp(`[: ]${port}\\s.*\\(LISTEN\\)`).test(l) || l.includes(`:${port} (LISTEN)`));
|
|
55
|
-
if (!matching)
|
|
56
|
-
return resolve({ inUse: false });
|
|
57
|
-
const parts = matching.trim().split(/\s+/);
|
|
58
|
-
resolve({ command: parts[0], inUse: true, pid: Number(parts[1]) });
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
}
|
|
42
|
+
const platform_1 = require("./platform");
|
|
63
43
|
/**
|
|
64
44
|
* How a detached child is actually launched on this platform.
|
|
65
45
|
*
|
|
@@ -77,9 +57,23 @@ function checkPortInUse(port) {
|
|
|
77
57
|
* - the cascade tries a high limit first, falling back on machines with a
|
|
78
58
|
* lower `kern.maxfilesperproc`; `2>/dev/null` keeps it best-effort.
|
|
79
59
|
*
|
|
80
|
-
* Windows
|
|
81
|
-
*
|
|
82
|
-
*
|
|
60
|
+
* Windows has no `/bin/sh` and no RLIMIT_NOFILE, but it needs a wrapper for a
|
|
61
|
+
* different reason: **without one the log stays empty.** `detached` starts the
|
|
62
|
+
* child without a console. When that child is `cmd.exe` (cross-spawn runs every
|
|
63
|
+
* `.cmd` shim, `pnpm.cmd` included, through it), the console program below it
|
|
64
|
+
* gets a NEW console, and Windows then replaces the standard handles that
|
|
65
|
+
* `cmd.exe` merely inherited, our log file among them. The output lands in an
|
|
66
|
+
* extra console window instead, and `.lt-dev/*.log` stays at 0 bytes while the
|
|
67
|
+
* app runs. Measured on windows-latest (fake `pnpm.cmd` → node → node grandchild):
|
|
68
|
+
* 0 bytes this way, and 0 bytes even when `cmd.exe` redirects with `>>` itself.
|
|
69
|
+
* On the laptop an API died on start and its error sat unseen in that window.
|
|
70
|
+
*
|
|
71
|
+
* The fix is a small Node process in between (`windowsTrampoline`). libuv hands
|
|
72
|
+
* it the file explicitly, and it starts the real command with `stdio: 'inherit'`,
|
|
73
|
+
* which passes the handles explicitly again, so `cmd.exe` gets a console of its
|
|
74
|
+
* own and its children inherit both. Measured the same way: 94 child + 46
|
|
75
|
+
* grandchild lines in the file. The recorded pid is the trampoline's, so
|
|
76
|
+
* `taskkill /T` from it reaches the whole tree.
|
|
83
77
|
*
|
|
84
78
|
* Note for the POSIX path: because `spawn('/bin/sh', …)` almost always succeeds,
|
|
85
79
|
* a bogus `cmd` does not surface as `pid === undefined` — the inner `exec` fails
|
|
@@ -88,70 +82,145 @@ function checkPortInUse(port) {
|
|
|
88
82
|
* deliberately don't watch for that here: a detached, unref'd child's 127 exit is
|
|
89
83
|
* racy to observe, and callers must be self-correcting against real crashes anyway.
|
|
90
84
|
*/
|
|
91
|
-
function detachedSpawnCommand(cmd, args, platform = process.platform) {
|
|
85
|
+
function detachedSpawnCommand(cmd, args, platform = process.platform, nodePath = process.execPath) {
|
|
92
86
|
if (platform === 'win32')
|
|
93
|
-
return { args, command:
|
|
87
|
+
return { args: ['-e', windowsTrampoline(), '--', cmd, ...args], command: nodePath };
|
|
94
88
|
const raiseFdLimit = 'ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true';
|
|
95
89
|
return { args: ['-c', `${raiseFdLimit}; exec "$0" "$@"`, cmd, ...args], command: '/bin/sh' };
|
|
96
90
|
}
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
/**
|
|
92
|
+
* HTTP status of `url`, or null when the request could not be made at all.
|
|
93
|
+
*
|
|
94
|
+
* Node's own client rather than `curl`. That removes three separate hazards at
|
|
95
|
+
* once, all of which were live:
|
|
96
|
+
*
|
|
97
|
+
* - **`curl -o /dev/null` fails on Windows.** `/dev/null` is a file path there,
|
|
98
|
+
* not the null device (`NUL` is), so curl exits **23 — "client returned ERROR
|
|
99
|
+
* on write"** *after* a perfectly successful request. Any caller reading the
|
|
100
|
+
* exit code concluded the service was down. That is exactly what made
|
|
101
|
+
* `lt dev up` refuse with "caddy daemon is not running" against a Caddy that
|
|
102
|
+
* was running and answering 200.
|
|
103
|
+
* - **`curl` is an external dependency** we do not need.
|
|
104
|
+
* - a spawn per probe, where a socket does.
|
|
105
|
+
*
|
|
106
|
+
* TLS verification is off for https URLs, matching the `-k` this replaces: the
|
|
107
|
+
* targets are local Caddy vhosts with a private CA.
|
|
108
|
+
*/
|
|
109
|
+
function httpStatus(url, timeoutMs = 2000) {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
let settled = false;
|
|
112
|
+
const done = (value) => {
|
|
113
|
+
if (settled)
|
|
114
|
+
return;
|
|
115
|
+
settled = true;
|
|
116
|
+
resolve(value);
|
|
117
|
+
};
|
|
106
118
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
119
|
+
const parsed = new URL(url);
|
|
120
|
+
const client = parsed.protocol === 'https:' ? https_1.request : http_1.request;
|
|
121
|
+
const request = client(url, { rejectUnauthorized: false, timeout: timeoutMs }, (response) => {
|
|
122
|
+
var _a;
|
|
123
|
+
// The body is irrelevant; draining it lets the socket close promptly.
|
|
124
|
+
response.resume();
|
|
125
|
+
done((_a = response.statusCode) !== null && _a !== void 0 ? _a : null);
|
|
126
|
+
});
|
|
127
|
+
request.on('timeout', () => {
|
|
128
|
+
request.destroy();
|
|
129
|
+
done(null);
|
|
130
|
+
});
|
|
131
|
+
request.on('error', () => done(null));
|
|
132
|
+
request.end();
|
|
109
133
|
}
|
|
110
|
-
catch (
|
|
111
|
-
|
|
134
|
+
catch (_a) {
|
|
135
|
+
done(null);
|
|
112
136
|
}
|
|
113
|
-
}
|
|
137
|
+
});
|
|
114
138
|
}
|
|
115
139
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
140
|
+
* True when something accepts a TCP connection on `127.0.0.1:port`.
|
|
141
|
+
*
|
|
142
|
+
* 127.0.0.1 rather than `localhost`: the components bind it explicitly
|
|
143
|
+
* (`dev-env.ts` sets `HOST`/`NITRO_HOST`) and Caddy proxies to it, while
|
|
144
|
+
* `localhost` can resolve to `::1` first and miss an IPv4-only listener — the
|
|
145
|
+
* trap `caddy.ts` already documents for the reverse proxy.
|
|
118
146
|
*/
|
|
119
|
-
function
|
|
120
|
-
return
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
147
|
+
function isPortBound(port, timeoutMs = 700) {
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const socket = new net_1.Socket();
|
|
150
|
+
let settled = false;
|
|
151
|
+
const done = (value) => {
|
|
152
|
+
if (settled)
|
|
153
|
+
return;
|
|
154
|
+
settled = true;
|
|
155
|
+
socket.destroy();
|
|
156
|
+
resolve(value);
|
|
157
|
+
};
|
|
158
|
+
socket.setTimeout(timeoutMs);
|
|
159
|
+
socket.once('connect', () => done(true));
|
|
160
|
+
socket.once('timeout', () => done(false));
|
|
161
|
+
socket.once('error', () => done(false));
|
|
162
|
+
socket.connect(port, '127.0.0.1');
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* End a detached process tree: SIGTERM to its group on POSIX (single-PID
|
|
167
|
+
* fallback), `taskkill /T /F` on Windows — forceful there, see `killWindowsTree`.
|
|
168
|
+
*
|
|
169
|
+
* Returns false without signalling anything when `planTermination` refuses the pid.
|
|
170
|
+
*/
|
|
171
|
+
function killProcessGroup(pid, options = {}) {
|
|
172
|
+
const plan = planTermination(pid, options.platform);
|
|
173
|
+
if (plan.kind === 'refuse')
|
|
174
|
+
return false;
|
|
175
|
+
if (plan.kind === 'taskkill')
|
|
176
|
+
return killWindowsTree(plan.target, options);
|
|
177
|
+
return signalGroup(plan.target, 'SIGTERM', options);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Decide whether `pid` may be signalled, and how. Pure — the only gate between
|
|
181
|
+
* a number read from disk (`state.json`) or from `lsof` and a real signal.
|
|
182
|
+
*
|
|
183
|
+
* Refused, and why each one matters:
|
|
184
|
+
* - **Not a plausible pid** (`isValidPid`): 0, negative, fractional, NaN.
|
|
185
|
+
* - **pid 1** on POSIX: `-1` is not a process group, it is the kill(2)
|
|
186
|
+
* BROADCAST — every process this user may signal. On 2026-09-23 a test called
|
|
187
|
+
* `killProcessGroup(1)`; the SIGTERM took down every terminal and session and
|
|
188
|
+
* the Mac rebooted two minutes later. A corrupted `state.json` holding `1`
|
|
189
|
+
* would do the same through `lt dev down`. And pid 1 itself is launchd/init.
|
|
190
|
+
* - **pid ≤ 4** on Windows: 0 is the idle process, 4 is `System`.
|
|
191
|
+
* - **This process and its parent**: a group signal to either reaches the CLI
|
|
192
|
+
* itself and whatever launched it.
|
|
193
|
+
*/
|
|
194
|
+
function planTermination(pid, platform = process.platform, self = { pid: process.pid, ppid: process.ppid }) {
|
|
195
|
+
if (!(0, dev_state_1.isValidPid)(pid))
|
|
196
|
+
return { kind: 'refuse', reason: `not a valid pid: ${String(pid)}` };
|
|
197
|
+
const windows = (0, platform_1.isWindows)(platform);
|
|
198
|
+
if (pid <= (windows ? 4 : 1))
|
|
199
|
+
return { kind: 'refuse', reason: `pid ${pid} is a system process` };
|
|
200
|
+
if (pid === self.pid || pid === self.ppid)
|
|
201
|
+
return { kind: 'refuse', reason: `pid ${pid} is this CLI or its parent` };
|
|
202
|
+
const target = pid;
|
|
203
|
+
return windows ? { kind: 'taskkill', target } : { kind: 'group', target };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Probe `ports` for listeners and, where the platform can tell, their owners.
|
|
207
|
+
*
|
|
208
|
+
* The connects run in parallel — one round trip to loopback, not N.
|
|
209
|
+
*/
|
|
210
|
+
function probePorts(ports_1) {
|
|
211
|
+
return __awaiter(this, arguments, void 0, function* (ports, options = {}) {
|
|
212
|
+
const unique = [...new Set(ports)];
|
|
213
|
+
if (unique.length === 0) {
|
|
214
|
+
return { bound: new Set(), owners: new Map(), ownersUnavailable: false };
|
|
215
|
+
}
|
|
216
|
+
const results = yield Promise.all(unique.map((port) => __awaiter(this, void 0, void 0, function* () { return [port, yield isPortBound(port)]; })));
|
|
217
|
+
const bound = new Set(results.filter(([, up]) => up).map(([port]) => port));
|
|
218
|
+
if (bound.size === 0) {
|
|
219
|
+
// Nothing to attribute; skip the subprocess entirely.
|
|
220
|
+
return { bound, owners: new Map(), ownersUnavailable: false };
|
|
221
|
+
}
|
|
222
|
+
const owners = yield portOwners([...bound], options);
|
|
223
|
+
return { bound, owners: owners !== null && owners !== void 0 ? owners : new Map(), ownersUnavailable: owners === null };
|
|
155
224
|
});
|
|
156
225
|
}
|
|
157
226
|
/**
|
|
@@ -194,7 +263,7 @@ function rotateLogFile(logFile) {
|
|
|
194
263
|
*/
|
|
195
264
|
function runChildInherit(cmd, args, opts) {
|
|
196
265
|
return new Promise((resolve) => {
|
|
197
|
-
const child = (0,
|
|
266
|
+
const child = (0, platform_1.spawnCmd)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: 'inherit' });
|
|
198
267
|
child.on('error', () => resolve(1));
|
|
199
268
|
child.on('close', (code) => resolve(code));
|
|
200
269
|
});
|
|
@@ -222,7 +291,7 @@ function runChildToFile(cmd, args, opts) {
|
|
|
222
291
|
return new Promise((resolve) => {
|
|
223
292
|
let child;
|
|
224
293
|
try {
|
|
225
|
-
child = (0,
|
|
294
|
+
child = (0, platform_1.spawnCmd)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['ignore', out, out] });
|
|
226
295
|
}
|
|
227
296
|
catch (_a) {
|
|
228
297
|
close();
|
|
@@ -261,11 +330,13 @@ function spawnDetached(cmd, args, opts) {
|
|
|
261
330
|
let child;
|
|
262
331
|
try {
|
|
263
332
|
const spawned = detachedSpawnCommand(cmd, args);
|
|
264
|
-
child = (0,
|
|
333
|
+
child = (0, platform_1.spawnCmd)(spawned.command, spawned.args, {
|
|
265
334
|
cwd: opts.cwd,
|
|
266
335
|
detached: true,
|
|
267
336
|
env: opts.env,
|
|
268
337
|
stdio: ['ignore', out, out],
|
|
338
|
+
// Windows only: keep the trampoline's console out of sight. No effect on POSIX.
|
|
339
|
+
windowsHide: true,
|
|
269
340
|
});
|
|
270
341
|
// spawn reports "could not start this at all" (missing executable, bad cwd)
|
|
271
342
|
// through an ASYNCHRONOUS 'error' event — the try/catch around spawn() never
|
|
@@ -309,36 +380,37 @@ function spawnDetached(cmd, args, opts) {
|
|
|
309
380
|
* is gone by the end, false if it somehow survived even SIGKILL.
|
|
310
381
|
*/
|
|
311
382
|
function terminateProcessGroup(pid_1) {
|
|
312
|
-
return __awaiter(this, arguments, void 0, function* (pid, graceMs = 4000) {
|
|
313
|
-
|
|
383
|
+
return __awaiter(this, arguments, void 0, function* (pid, graceMs = 4000, options = {}) {
|
|
384
|
+
var _a;
|
|
385
|
+
const plan = planTermination(pid, options.platform);
|
|
386
|
+
if (plan.kind === 'refuse')
|
|
314
387
|
return false;
|
|
315
|
-
|
|
388
|
+
const isAlive = (_a = options.isAlive) !== null && _a !== void 0 ? _a : dev_state_1.isPidAlive;
|
|
389
|
+
if (!isAlive(plan.target))
|
|
316
390
|
return true;
|
|
317
391
|
// Phase 1 — graceful: SIGTERM the group (single-PID fallback inside).
|
|
318
|
-
killProcessGroup
|
|
392
|
+
// On Windows there is no such thing: `killProcessGroup` is already `/T /F`
|
|
393
|
+
// there, so the two phases collapse into one. The polling stays, because the
|
|
394
|
+
// RETURN VALUE still has to be honest about whether the pid actually went.
|
|
395
|
+
killProcessGroup(plan.target, options);
|
|
319
396
|
const deadline = Date.now() + Math.max(0, graceMs);
|
|
320
397
|
while (Date.now() < deadline) {
|
|
321
|
-
if (!(
|
|
398
|
+
if (!isAlive(plan.target))
|
|
322
399
|
return true;
|
|
323
400
|
yield delay(150);
|
|
324
401
|
}
|
|
325
402
|
// Phase 2 — forced: SIGKILL the group, then the single PID.
|
|
326
|
-
if (!(
|
|
403
|
+
if (!isAlive(plan.target))
|
|
327
404
|
return true;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
catch (_a) {
|
|
332
|
-
/* group already gone or pid is not a group leader */
|
|
333
|
-
}
|
|
334
|
-
try {
|
|
335
|
-
process.kill(pid, 'SIGKILL');
|
|
405
|
+
if (plan.kind === 'taskkill') {
|
|
406
|
+
// Nothing harder exists; a second `/T /F` is the only escalation there is.
|
|
407
|
+
killWindowsTree(plan.target, options);
|
|
336
408
|
}
|
|
337
|
-
|
|
338
|
-
|
|
409
|
+
else {
|
|
410
|
+
signalGroup(plan.target, 'SIGKILL', options);
|
|
339
411
|
}
|
|
340
412
|
yield delay(150);
|
|
341
|
-
return !(
|
|
413
|
+
return !isAlive(plan.target);
|
|
342
414
|
});
|
|
343
415
|
}
|
|
344
416
|
/**
|
|
@@ -369,32 +441,195 @@ abort = () => false) {
|
|
|
369
441
|
const start = Date.now();
|
|
370
442
|
return new Promise((resolve) => {
|
|
371
443
|
const tick = () => {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
let status = '';
|
|
377
|
-
(_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (status += String(b)));
|
|
378
|
-
const retry = () => {
|
|
444
|
+
httpStatus(url, 2000).then((status) => {
|
|
445
|
+
if (status !== null && ready(status)) {
|
|
446
|
+
return resolve(true);
|
|
447
|
+
}
|
|
379
448
|
// Order matters: a LAST probe already ran above, so a server that came up
|
|
380
449
|
// just before dying is still reported ready. Only then does `abort` end it.
|
|
381
|
-
if (
|
|
450
|
+
if (abort()) {
|
|
382
451
|
return resolve(false);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (Number.isFinite(code) && code > 0 && ready(code))
|
|
389
|
-
return resolve(true);
|
|
390
|
-
retry();
|
|
452
|
+
}
|
|
453
|
+
if (Date.now() - start >= timeoutMs) {
|
|
454
|
+
return resolve(false);
|
|
455
|
+
}
|
|
456
|
+
setTimeout(tick, 250);
|
|
391
457
|
});
|
|
392
|
-
child.on('error', retry);
|
|
393
458
|
};
|
|
394
459
|
tick();
|
|
395
460
|
});
|
|
396
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* Source of the Windows trampoline (see `detachedSpawnCommand`), run as `node -e`.
|
|
464
|
+
* cross-spawn is required by absolute path because `-e` resolves modules from the
|
|
465
|
+
* child's cwd, i.e. the user's project, not from the CLI. It exits with the
|
|
466
|
+
* command's code, so a dead command still reads as a dead pid.
|
|
467
|
+
*/
|
|
468
|
+
function windowsTrampoline() {
|
|
469
|
+
const crossSpawnPath = JSON.stringify(require.resolve('cross-spawn'));
|
|
470
|
+
return [
|
|
471
|
+
`const spawn = require(${crossSpawnPath});`,
|
|
472
|
+
'const [cmd, ...args] = process.argv.slice(1);',
|
|
473
|
+
"const child = spawn(cmd, args, { stdio: 'inherit', windowsHide: true });",
|
|
474
|
+
"child.on('error', (e) => { process.stderr.write('lt dev: could not start ' + cmd + ': ' + e.message + '\\n'); process.exit(127); });",
|
|
475
|
+
"child.on('exit', (code) => process.exit(code === null ? 1 : code));",
|
|
476
|
+
].join(' '); // one line: a newline inside a Windows command-line argument is asking for trouble
|
|
477
|
+
}
|
|
478
|
+
/** stdout of a command, or null when it could not run. */
|
|
479
|
+
function captureStdout(command, args) {
|
|
480
|
+
return new Promise((resolve) => {
|
|
481
|
+
var _a;
|
|
482
|
+
const child = (0, child_process_1.spawn)(command, args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
483
|
+
let stdout = '';
|
|
484
|
+
let errored = false;
|
|
485
|
+
(_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b)));
|
|
486
|
+
child.on('error', () => (errored = true));
|
|
487
|
+
child.on('close', () => resolve(errored ? null : stdout));
|
|
488
|
+
});
|
|
489
|
+
}
|
|
397
490
|
/** Promise-based delay used by the graceful→forced termination escalation. */
|
|
398
491
|
function delay(ms) {
|
|
399
492
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
400
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* Windows has no process groups in this sense, and **no gentle step.**
|
|
496
|
+
*
|
|
497
|
+
* `taskkill /PID <pid> /T` without `/F` was measured on a Windows laptop (a node
|
|
498
|
+
* parent with two children holding port 3999): it fails on the children ("must be
|
|
499
|
+
* forcefully terminated") and leaves the port bound — i.e. it looks like a refusal,
|
|
500
|
+
* not like a graceful stop. Only `/T /F` actually ends the tree and frees the
|
|
501
|
+
* port. So on Windows this function is forceful where its POSIX twin is polite,
|
|
502
|
+
* and two consequences follow that a caller has to know:
|
|
503
|
+
*
|
|
504
|
+
* - **Shutdown hooks do not run.** No SIGTERM handler, no `onApplicationShutdown`,
|
|
505
|
+
* no ordered close of a Mongo connection. In practice the components write
|
|
506
|
+
* nothing on exit that the next start does not rebuild, but "in practice" is not
|
|
507
|
+
* "never": a project that holds something open at shutdown gets no chance there.
|
|
508
|
+
* - **`/T` walks the child TREE from a snapshot, not a process group.** A
|
|
509
|
+
* grandchild that has been re-parented (its parent exited first) is no longer in
|
|
510
|
+
* that tree and survives. The negative-PID kill on POSIX has no such hole,
|
|
511
|
+
* because group membership is inherited and does not change when a parent dies.
|
|
512
|
+
*
|
|
513
|
+
* The pid is the one `spawnDetached` recorded. On Windows that is cross-spawn's
|
|
514
|
+
* `cmd.exe`, and the package manager plus everything it started hang below it — so
|
|
515
|
+
* the tree walk should reach them. That part is NOT yet measured on a real
|
|
516
|
+
* `lt dev` stack — only on the synthetic tree above.
|
|
517
|
+
*/
|
|
518
|
+
function killWindowsTree(target, options) {
|
|
519
|
+
var _a, _b;
|
|
520
|
+
const run = (_a = options.run) !== null && _a !== void 0 ? _a : ((command, args) => (0, platform_1.spawnCmdSync)(command, args, { stdio: 'ignore' }));
|
|
521
|
+
const isAlive = (_b = options.isAlive) !== null && _b !== void 0 ? _b : dev_state_1.isPidAlive;
|
|
522
|
+
const result = run('taskkill', ['/PID', String(target), '/T', '/F']);
|
|
523
|
+
// taskkill exits non-zero when the pid is already gone, which is success for us.
|
|
524
|
+
return result.status === 0 || !isAlive(target);
|
|
525
|
+
}
|
|
526
|
+
/** `lsof` path. Parses the positional column layout. */
|
|
527
|
+
function lsofPortOwners(ports, capture) {
|
|
528
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
529
|
+
const portArgs = ports.flatMap((p) => ['-iTCP', `-iTCP:${p}`]);
|
|
530
|
+
const stdout = yield capture('lsof', ['-sTCP:LISTEN', '-nP', ...portArgs]);
|
|
531
|
+
if (stdout === null)
|
|
532
|
+
return null;
|
|
533
|
+
const result = new Map();
|
|
534
|
+
for (const line of stdout.split('\n')) {
|
|
535
|
+
if (!line || line.startsWith('COMMAND'))
|
|
536
|
+
continue;
|
|
537
|
+
const parts = line.trim().split(/\s+/);
|
|
538
|
+
if (parts.length < 9)
|
|
539
|
+
continue;
|
|
540
|
+
const portMatch = parts[8].match(/:(\d+)$/);
|
|
541
|
+
if (!portMatch)
|
|
542
|
+
continue;
|
|
543
|
+
const port = Number(portMatch[1]);
|
|
544
|
+
if (ports.includes(port) && /\(LISTEN\)/.test(line)) {
|
|
545
|
+
result.set(port, { command: parts[0], pid: Number(parts[1]) });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return result;
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Owning process per port, or null when the lookup could not run.
|
|
553
|
+
*
|
|
554
|
+
* POSIX asks `lsof`. Windows asks `netstat -ano` for the pid and `tasklist` for
|
|
555
|
+
* the name — both ship with the OS and neither needs PowerShell, so this works
|
|
556
|
+
* in cmd.exe too.
|
|
557
|
+
*/
|
|
558
|
+
function portOwners(ports, options) {
|
|
559
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
560
|
+
var _a;
|
|
561
|
+
const capture = (_a = options.capture) !== null && _a !== void 0 ? _a : captureStdout;
|
|
562
|
+
return (0, platform_1.isWindows)(options.platform) ? windowsPortOwners(ports, capture) : lsofPortOwners(ports, capture);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* The one place in `src/` that signals a negative pid. It accepts only a
|
|
567
|
+
* `SignalTarget`, i.e. a pid `planTermination` has cleared — never a raw number.
|
|
568
|
+
* Returns true when either the group or the single pid took the signal.
|
|
569
|
+
*/
|
|
570
|
+
function signalGroup(target, signal, options) {
|
|
571
|
+
var _a;
|
|
572
|
+
const send = (_a = options.signal) !== null && _a !== void 0 ? _a : ((pid, sig) => process.kill(pid, sig));
|
|
573
|
+
try {
|
|
574
|
+
send(-target, signal);
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
catch (_b) {
|
|
578
|
+
// Not a group leader, or the group is gone: fall back to the pid itself.
|
|
579
|
+
try {
|
|
580
|
+
send(target, signal);
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
catch (_c) {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/** `netstat -ano` + `tasklist` path. */
|
|
589
|
+
function windowsPortOwners(ports, capture) {
|
|
590
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
591
|
+
var _a;
|
|
592
|
+
const netstat = yield capture('netstat', ['-ano']);
|
|
593
|
+
if (netstat === null)
|
|
594
|
+
return null;
|
|
595
|
+
const pidByPort = new Map();
|
|
596
|
+
for (const line of netstat.split('\n')) {
|
|
597
|
+
// ` TCP 127.0.0.1:4000 0.0.0.0:0 LISTENING 1234`
|
|
598
|
+
const parts = line.trim().split(/\s+/);
|
|
599
|
+
if (parts.length < 5 || !/^TCP$/i.test(parts[0]) || !/^LISTENING$/i.test(parts[3]))
|
|
600
|
+
continue;
|
|
601
|
+
const portMatch = parts[1].match(/:(\d+)$/);
|
|
602
|
+
const pid = Number(parts[4]);
|
|
603
|
+
if (!portMatch || !Number.isInteger(pid))
|
|
604
|
+
continue;
|
|
605
|
+
const port = Number(portMatch[1]);
|
|
606
|
+
if (ports.includes(port) && !pidByPort.has(port))
|
|
607
|
+
pidByPort.set(port, pid);
|
|
608
|
+
}
|
|
609
|
+
const names = yield windowsProcessNames([...new Set(pidByPort.values())], capture);
|
|
610
|
+
const result = new Map();
|
|
611
|
+
for (const [port, pid] of pidByPort) {
|
|
612
|
+
result.set(port, { command: (_a = names.get(pid)) !== null && _a !== void 0 ? _a : String(pid), pid });
|
|
613
|
+
}
|
|
614
|
+
return result;
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
/** Image name per pid via `tasklist`, best effort — an unnamed pid is still a pid. */
|
|
618
|
+
function windowsProcessNames(pids, capture) {
|
|
619
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
620
|
+
const names = new Map();
|
|
621
|
+
if (pids.length === 0)
|
|
622
|
+
return names;
|
|
623
|
+
// `/FO CSV /NH` keeps the output parseable without a header line.
|
|
624
|
+
const filters = pids.flatMap((pid) => ['/FI', `PID eq ${pid}`]);
|
|
625
|
+
const out = yield capture('tasklist', [...filters, '/FO', 'CSV', '/NH']);
|
|
626
|
+
if (out === null)
|
|
627
|
+
return names;
|
|
628
|
+
for (const line of out.split('\n')) {
|
|
629
|
+
const match = line.match(/^"([^"]+)","(\d+)"/);
|
|
630
|
+
if (match)
|
|
631
|
+
names.set(Number(match[2]), match[1]);
|
|
632
|
+
}
|
|
633
|
+
return names;
|
|
634
|
+
});
|
|
635
|
+
}
|
package/build/lib/dev-project.js
CHANGED
|
@@ -94,7 +94,20 @@ function deriveTicketDbName(devDbName, ticketId) {
|
|
|
94
94
|
* and build allowlists there), and an npm workspace may use `packages/*`
|
|
95
95
|
* rather than the lt `projects/*` convention.
|
|
96
96
|
*/
|
|
97
|
-
function resolveLayout(
|
|
97
|
+
function resolveLayout(cwdOrRelative, filesystem) {
|
|
98
|
+
// Absolutised HERE rather than at the call sites, so no caller can inherit the
|
|
99
|
+
// trap. `lt fullstack init` passes `kebabCase(name)` — a bare relative segment
|
|
100
|
+
// — and the resulting `root` was written verbatim into `~/.lenneTech/projects.json`
|
|
101
|
+
// as the project's `path`. Everything that later asks "does this checkout still
|
|
102
|
+
// exist?" then resolved it against whatever directory the process happened to
|
|
103
|
+
// start in: `planRegistryPrune` declared a live project orphaned and reclaimed
|
|
104
|
+
// its slug and reserved ports, and `detectSlugConflict` produced the nonsense
|
|
105
|
+
// "slug 't-5' is also registered to another checkout: t-5".
|
|
106
|
+
//
|
|
107
|
+
// Not Windows-specific. It surfaced there because those projects were created
|
|
108
|
+
// by `lt fullstack init`; on macOS the entries happen to come from `lt dev up`
|
|
109
|
+
// run inside the project, where the cwd is already absolute.
|
|
110
|
+
const cwd = (0, path_1.resolve)(cwdOrRelative);
|
|
98
111
|
const subContext = (0, workspace_integration_1.detectSubProjectContext)(cwd, filesystem);
|
|
99
112
|
if (subContext)
|
|
100
113
|
return monorepoLayout(subContext.workspaceRoot);
|