@lenne.tech/cli 1.48.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.
Files changed (40) hide show
  1. package/build/commands/dev/doctor.js +27 -10
  2. package/build/commands/dev/down.js +48 -5
  3. package/build/commands/dev/status.js +32 -8
  4. package/build/commands/dev/test.js +5 -3
  5. package/build/commands/dev/up.js +59 -21
  6. package/build/commands/fullstack/init.js +12 -3
  7. package/build/commands/git/squash.js +4 -1
  8. package/build/extensions/api-mode.js +69 -0
  9. package/build/extensions/frontend-helper.js +11 -12
  10. package/build/extensions/server.js +43 -33
  11. package/build/lib/caddy.js +17 -10
  12. package/build/lib/check-template-sync.js +62 -2
  13. package/build/lib/config-env-graphql.js +106 -0
  14. package/build/lib/cross-env.js +94 -0
  15. package/build/lib/dev-api-launch.js +34 -8
  16. package/build/lib/dev-env-bridge.js +7 -0
  17. package/build/lib/dev-env.js +44 -4
  18. package/build/lib/dev-log-tail.js +91 -0
  19. package/build/lib/dev-package-manager.js +13 -9
  20. package/build/lib/dev-patches.js +15 -5
  21. package/build/lib/dev-process.js +362 -127
  22. package/build/lib/dev-project.js +14 -1
  23. package/build/lib/dev-prune.js +15 -5
  24. package/build/lib/dev-service.js +29 -15
  25. package/build/lib/dev-state.js +40 -4
  26. package/build/lib/dev-test-session.js +141 -73
  27. package/build/lib/dev-ticket.js +13 -1
  28. package/build/lib/fail-run.js +11 -0
  29. package/build/lib/heal-vendor-migrate-store.js +4 -3
  30. package/build/lib/hoist-workspace-pnpm-config.js +17 -6
  31. package/build/lib/migrate-scripts.js +69 -0
  32. package/build/lib/module-specifiers.js +144 -0
  33. package/build/lib/platform.js +53 -1
  34. package/build/lib/spinner-lifetime.js +245 -0
  35. package/build/templates/check/check.mjs +59 -9
  36. package/build/templates/check/lt-monorepo-pin.json +4 -4
  37. package/build/templates/nest-server-starter/README.md.ejs +13 -6
  38. package/docs/commands.md +16 -12
  39. package/package.json +4 -1
  40. package/build/lib/strip-comments.js +0 -78
@@ -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.listenSnapshot = listenSnapshot;
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 via `process.kill(-pid, …)`.
28
- * - `listenSnapshot` / `checkPortInUse`: thin lsof wrappers used by
29
- * `lt dev doctor` to detect port collisions.
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 gets the command directly. There is no `/bin/sh` to run the wrapper —
81
- * spawning it fails outright — and no RLIMIT_NOFILE for `ulimit` to raise, so
82
- * the wrapper has nothing to offer there even in principle.
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: cmd };
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
- /** Send SIGTERM to a detached process group; falls back to single-PID kill. */
98
- function killProcessGroup(pid) {
99
- if (!(0, dev_state_1.isValidPid)(pid))
100
- return false;
101
- try {
102
- process.kill(-pid, 'SIGTERM');
103
- return true;
104
- }
105
- catch (_a) {
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
- process.kill(pid, 'SIGTERM');
108
- return true;
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 (_b) {
111
- return false;
134
+ catch (_a) {
135
+ done(null);
112
136
  }
113
- }
137
+ });
114
138
  }
115
139
  /**
116
- * Multi-port lsof snapshot — single subprocess for N ports.
117
- * Returns map<port, {command, pid}> for ports that are in use.
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 listenSnapshot(ports) {
120
- return __awaiter(this, void 0, void 0, function* () {
121
- const result = new Map();
122
- if (ports.length === 0)
123
- return result;
124
- return new Promise((resolve) => {
125
- var _a;
126
- const portArgs = ports.flatMap((p) => ['-iTCP', `-iTCP:${p}`]);
127
- const child = (0, child_process_1.spawn)('lsof', ['-sTCP:LISTEN', '-nP', ...portArgs], { stdio: ['ignore', 'pipe', 'pipe'] });
128
- let stdout = '';
129
- let errored = false;
130
- (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (stdout += String(b)));
131
- child.on('error', () => (errored = true));
132
- child.on('close', () => {
133
- if (errored)
134
- return resolve(result);
135
- for (const line of stdout.split('\n')) {
136
- if (!line || line.startsWith('COMMAND'))
137
- continue;
138
- const parts = line.trim().split(/\s+/);
139
- if (parts.length < 9)
140
- continue;
141
- const command = parts[0];
142
- const pid = Number(parts[1]);
143
- const name = parts[8];
144
- const portMatch = name.match(/:(\d+)$/);
145
- if (!portMatch)
146
- continue;
147
- const port = Number(portMatch[1]);
148
- if (ports.includes(port) && /\(LISTEN\)/.test(line)) {
149
- result.set(port, { command, pid });
150
- }
151
- }
152
- resolve(result);
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, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: 'inherit' });
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, child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ['ignore', out, out] });
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, child_process_1.spawn)(spawned.command, spawned.args, {
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
- if (!(0, dev_state_1.isValidPid)(pid))
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
- if (!(0, dev_state_1.isPidAlive)(pid))
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(pid);
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 (!(0, dev_state_1.isPidAlive)(pid))
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 (!(0, dev_state_1.isPidAlive)(pid))
403
+ if (!isAlive(plan.target))
327
404
  return true;
328
- try {
329
- process.kill(-pid, 'SIGKILL');
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
- catch (_b) {
338
- /* already dead */
409
+ else {
410
+ signalGroup(plan.target, 'SIGKILL', options);
339
411
  }
340
412
  yield delay(150);
341
- return !(0, dev_state_1.isPidAlive)(pid);
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
- var _a;
373
- const child = (0, child_process_1.spawn)('curl', ['-sk', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '2', url], {
374
- stdio: ['ignore', 'pipe', 'ignore'],
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 (Date.now() - start > timeoutMs || abort())
450
+ if (abort()) {
382
451
  return resolve(false);
383
- setTimeout(tick, 500);
384
- };
385
- child.on('close', () => {
386
- const code = Number(status.trim());
387
- // `000` (curl could not connect) parses to 0 → never "ready".
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
+ }
@@ -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(cwd, filesystem) {
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);