@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.
@@ -117,18 +117,31 @@ const DoctorCommand = {
117
117
  line('WARN', colors.yellow, `Caddyfile validation: ${v.stderr.split('\n')[0]}`);
118
118
  }
119
119
  // 4. Port 80 / 443 free or held by Caddy
120
+ //
121
+ // "Is it bound" is now a TCP connect, so it answers on every platform — the
122
+ // old `lsof`-only probe degraded to a WARN on Windows and doctor then exited
123
+ // 0 while nothing worked. Only the OCCUPANT'S NAME can still be unavailable,
124
+ // and that is reported as its own state rather than folded into either
125
+ // verdict: a bound port we cannot attribute is not "free", and it is not
126
+ // proof that Caddy is blocked either.
127
+ const portProbe = yield (0, dev_process_1.probePorts)([80, 443]);
120
128
  for (const port of [80, 443]) {
121
- const r = yield (0, dev_process_1.checkPortInUse)(port);
122
- if (r === null)
123
- line('WARN', colors.yellow, `lsof unavailable — cannot probe port ${port}`);
124
- else if (!r.inUse)
129
+ if (!portProbe.bound.has(port)) {
125
130
  line('OK', colors.green, `port ${port} free`);
126
- else if (r.command === 'caddy')
127
- line('OK', colors.green, `port ${port} held by caddy (pid ${r.pid})`);
128
- else {
129
- line('FAIL', colors.red, `port ${port} held by ${r.command} (pid ${r.pid}) — Caddy cannot bind`);
130
- fails++;
131
+ continue;
132
+ }
133
+ const owner = portProbe.owners.get(port);
134
+ if (!owner) {
135
+ line('WARN', colors.yellow, `port ${port} is in use but the occupant could not be identified` +
136
+ `${portProbe.ownersUnavailable ? ' (no lsof/netstat available)' : ''}`);
137
+ continue;
131
138
  }
139
+ if (owner.command.toLowerCase().startsWith('caddy')) {
140
+ line('OK', colors.green, `port ${port} held by caddy (pid ${owner.pid})`);
141
+ continue;
142
+ }
143
+ line('FAIL', colors.red, `port ${port} held by ${owner.command} (pid ${owner.pid}) — Caddy cannot bind`);
144
+ fails++;
132
145
  }
133
146
  // 5. *.localhost resolves to 127.0.0.1
134
147
  const dnsOk = yield dnsResolvesLocalhost('lt-dev-doctor.localhost');
@@ -273,7 +286,11 @@ const DoctorCommand = {
273
286
  */
274
287
  function dnsResolvesLocalhost(host) {
275
288
  return new Promise((resolve) => {
276
- const child = (0, child_process_1.spawn)('node', [
289
+ const child = (0, child_process_1.spawn)(
290
+ // `process.execPath`, not a bare `node`: the CLI may run from a Node that
291
+ // is not on PATH at all (a version manager, a packaged install), and a
292
+ // different `node` on PATH would answer for a different runtime.
293
+ process.execPath, [
277
294
  '-e',
278
295
  `require('dns').lookup(${JSON.stringify(host)}, { all: true }, (e, addrs) => {
279
296
  if (e) process.exit(1);
@@ -16,12 +16,20 @@ const dev_project_1 = require("../../lib/dev-project");
16
16
  const dev_state_1 = require("../../lib/dev-state");
17
17
  const dev_test_session_1 = require("../../lib/dev-test-session");
18
18
  const dev_ticket_1 = require("../../lib/dev-ticket");
19
+ const platform_1 = require("../../lib/platform");
19
20
  /**
20
21
  * Stop the processes started by `lt dev up` and remove the project's
21
22
  * Caddy block.
22
23
  *
23
- * - SIGTERM is sent to the detached process GROUP (negative PID) so
24
- * children (Vite, Nest watcher) receive the signal too.
24
+ * - POSIX: SIGTERM to the detached process GROUP (negative PID), so children
25
+ * (Vite, Nest watcher) receive it too and can shut down gracefully. No
26
+ * escalation — `down` is the polite stop.
27
+ * - Windows: `taskkill /T /F`, i.e. FORCED, while `up`'s reclaim keeps the
28
+ * two-phase `terminateProcessGroup`. Not a choice: Windows has no gentle step
29
+ * (`/T` without `/F` was measured to leave the tree and its port alive), so
30
+ * shutdown hooks do not run there. Details in `killWindowsTree`.
31
+ * - Either way the pid is verified gone afterwards; a survivor is reported,
32
+ * never listed as stopped.
25
33
  * - The Caddy block is removed and `caddy reload` is invoked, so the
26
34
  * subdomain stops resolving immediately.
27
35
  */
@@ -46,10 +54,27 @@ const DownCommand = {
46
54
  stopped.push(`${name} (pid ${pid}, already dead)`);
47
55
  continue;
48
56
  }
49
- if ((0, dev_process_1.killProcessGroup)(pid))
57
+ // A pid the plan refuses (1, this CLI, a system pid — i.e. a corrupted
58
+ // state.json) is neither signalled nor offered as a copy-paste kill hint:
59
+ // `kill -9 -1` is the broadcast that rebooted a Mac on 2026-09-23.
60
+ const plan = (0, dev_process_1.planTermination)(pid);
61
+ if (plan.kind === 'refuse') {
62
+ warning(`Not stopping ${name}: ${plan.reason} — .lt-dev/state.json looks corrupted.`);
63
+ continue;
64
+ }
65
+ (0, dev_process_1.killProcessGroup)(pid);
66
+ // Verify rather than assume: `killProcessGroup` reports that the signal
67
+ // was delivered, not that the process went. A compiled API with shutdown
68
+ // hooks can sit on SIGTERM while it waits for Mongo; claiming "stopped"
69
+ // then sends the user into the next `lt dev up` with a port collision
70
+ // nobody can trace back.
71
+ if (yield waitForExit(pid, 3000)) {
50
72
  stopped.push(`${name} (pid ${pid})`);
51
- else
52
- warning(`Failed to stop ${name} (pid ${pid})`);
73
+ }
74
+ else {
75
+ warning(`${name} (pid ${pid}) did not stop — it may still hold its port.`);
76
+ info(colors.dim(` Check with \`lt dev status\`; force it with ${forceKillHint(pid)}`));
77
+ }
53
78
  }
54
79
  (0, dev_state_1.clearSession)(layout.root);
55
80
  }
@@ -98,3 +123,21 @@ const DownCommand = {
98
123
  }),
99
124
  };
100
125
  module.exports = DownCommand;
126
+ /** The command that actually ends a process tree on this platform. */
127
+ function forceKillHint(pid) {
128
+ // `/F` is not optional on Windows: measured, `taskkill /PID <pid> /T` without it
129
+ // fails on the children and leaves the port bound.
130
+ return (0, platform_1.isWindows)() ? `\`taskkill /PID ${pid} /T /F\`` : `\`kill -9 -${pid}\``;
131
+ }
132
+ /** Poll until `pid` is gone, or the budget runs out. */
133
+ function waitForExit(pid, budgetMs) {
134
+ return __awaiter(this, void 0, void 0, function* () {
135
+ const deadline = Date.now() + budgetMs;
136
+ while (Date.now() < deadline) {
137
+ if (!(0, dev_state_1.isPidAlive)(pid))
138
+ return true;
139
+ yield new Promise((resolve) => setTimeout(resolve, 100));
140
+ }
141
+ return !(0, dev_state_1.isPidAlive)(pid);
142
+ });
143
+ }
@@ -9,7 +9,9 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
+ const path_1 = require("path");
12
13
  const caddy_1 = require("../../lib/caddy");
14
+ const dev_log_tail_1 = require("../../lib/dev-log-tail");
13
15
  const dev_process_1 = require("../../lib/dev-process");
14
16
  const dev_project_1 = require("../../lib/dev-project");
15
17
  const dev_state_1 = require("../../lib/dev-state");
@@ -50,7 +52,7 @@ const StatusCommand = {
50
52
  if (e.internalPorts.app)
51
53
  allPorts.push(e.internalPorts.app);
52
54
  }
53
- const snap = yield (0, dev_process_1.listenSnapshot)(allPorts);
55
+ const probe = yield (0, dev_process_1.probePorts)(allPorts);
54
56
  for (const slug of slugs) {
55
57
  const e = reg.projects[slug];
56
58
  const session = (0, dev_state_1.loadSession)(e.path);
@@ -60,14 +62,14 @@ const StatusCommand = {
60
62
  if (e.internalPorts.api) {
61
63
  comps.push((0, dev_state_1.classifyComponentHealth)({
62
64
  pid: session === null || session === void 0 ? void 0 : session.pids.api,
63
- portBound: snap.has(e.internalPorts.api),
65
+ portBound: probe.bound.has(e.internalPorts.api),
64
66
  startedAt: session === null || session === void 0 ? void 0 : session.startedAt,
65
67
  }));
66
68
  }
67
69
  if (e.internalPorts.app) {
68
70
  comps.push((0, dev_state_1.classifyComponentHealth)({
69
71
  pid: session === null || session === void 0 ? void 0 : session.pids.app,
70
- portBound: snap.has(e.internalPorts.app),
72
+ portBound: probe.bound.has(e.internalPorts.app),
71
73
  startedAt: session === null || session === void 0 ? void 0 : session.startedAt,
72
74
  }));
73
75
  }
@@ -186,15 +188,15 @@ const StatusCommand = {
186
188
  // ts-node leaves nodemon alive ("waiting for file changes"), so the
187
189
  // wrapper PID alone reads as "running" while nothing serves the port.
188
190
  const ports = [entry.internalPorts.api, entry.internalPorts.app].filter((p) => typeof p === 'number');
189
- const snap = yield (0, dev_process_1.listenSnapshot)(ports);
191
+ const probe = yield (0, dev_process_1.probePorts)(ports);
190
192
  const apiHealth = (0, dev_state_1.classifyComponentHealth)({
191
193
  pid: session.pids.api,
192
- portBound: entry.internalPorts.api ? snap.has(entry.internalPorts.api) : false,
194
+ portBound: entry.internalPorts.api ? probe.bound.has(entry.internalPorts.api) : false,
193
195
  startedAt: session.startedAt,
194
196
  });
195
197
  const appHealth = (0, dev_state_1.classifyComponentHealth)({
196
198
  pid: session.pids.app,
197
- portBound: entry.internalPorts.app ? snap.has(entry.internalPorts.app) : false,
199
+ portBound: entry.internalPorts.app ? probe.bound.has(entry.internalPorts.app) : false,
198
200
  startedAt: session.startedAt,
199
201
  });
200
202
  const label = (health) => health === 'running'
@@ -216,8 +218,13 @@ const StatusCommand = {
216
218
  info('');
217
219
  info(colors.bold(' Live upstream state'));
218
220
  for (const p of ports) {
219
- const r = snap.get(p);
220
- info(` ${p}: ${r ? colors.green(`bound to ${r.command} (pid ${r.pid})`) : colors.dim('free')}`);
221
+ if (!probe.bound.has(p)) {
222
+ info(` ${p}: ${colors.dim('free')}`);
223
+ continue;
224
+ }
225
+ const owner = probe.owners.get(p);
226
+ // "bound" is measured; the occupant's name is not always obtainable.
227
+ info(` ${p}: ${owner ? colors.green(`bound to ${owner.command} (pid ${owner.pid})`) : colors.green('bound')}`);
221
228
  }
222
229
  }
223
230
  // Surface any present-but-not-serving component (crashed OR dead) and
@@ -241,6 +248,23 @@ const StatusCommand = {
241
248
  info('');
242
249
  warning(` ${down.join(' + ')} not serving${crashed ? ' (supervisor still up — crashed)' : ''}. ` +
243
250
  `Run \`lt dev up\` to restart ${down.length === 1 ? 'it' : 'them'}.`);
251
+ // Why it is down is in its log, so show it here instead of sending the
252
+ // reader to look. An empty log is said out loud: that is a finding too.
253
+ for (const name of down) {
254
+ (0, dev_log_tail_1.describeLog)((0, dev_log_tail_1.diagnoseLog)((0, path_1.join)(layout.root, '.lt-dev', `${name}.log`), 8)).forEach((l) => info(colors.dim(` ${l}`)));
255
+ }
256
+ }
257
+ // A log that exists but stays empty while its component runs: the output
258
+ // is going somewhere else (on Windows it once went to an extra console
259
+ // window). Worse than no log, because it looks trustworthy.
260
+ for (const [name, present] of [
261
+ ['api', apiPresent],
262
+ ['app', appPresent],
263
+ ]) {
264
+ const logDiagnosis = (0, dev_log_tail_1.diagnoseLog)((0, path_1.join)(layout.root, '.lt-dev', `${name}.log`));
265
+ if (present && !down.includes(name) && (0, dev_log_tail_1.isSilentLog)(logDiagnosis, session.startedAt, Date.now())) {
266
+ warning(` ${name}: ${logDiagnosis.file} is still EMPTY — its output is not being captured.`);
267
+ }
244
268
  }
245
269
  }
246
270
  // Isolated test stack (`lt dev test`), if one is up / left with --keep.
@@ -17,6 +17,7 @@ const dev_api_launch_1 = require("../../lib/dev-api-launch");
17
17
  const dev_env_1 = require("../../lib/dev-env");
18
18
  const dev_env_bridge_1 = require("../../lib/dev-env-bridge");
19
19
  const dev_identity_1 = require("../../lib/dev-identity");
20
+ const dev_log_tail_1 = require("../../lib/dev-log-tail");
20
21
  const dev_package_manager_1 = require("../../lib/dev-package-manager");
21
22
  const dev_patches_1 = require("../../lib/dev-patches");
22
23
  const dev_process_1 = require("../../lib/dev-process");
@@ -144,17 +145,6 @@ const UpCommand = {
144
145
  // plugin hook surfaces the ticket context per prompt instead (from the
145
146
  // gitignored `.lt-dev/ticket` marker). For the base project we keep the
146
147
  // committed URL block up to date as before.
147
- if (!ticket) {
148
- const claudeCandidates = [
149
- (0, path_1.join)(layout.root, 'CLAUDE.md'),
150
- ...(layout.apiDir ? [(0, path_1.join)(layout.apiDir, 'CLAUDE.md')] : []),
151
- ...(layout.appDir ? [(0, path_1.join)(layout.appDir, 'CLAUDE.md')] : []),
152
- ];
153
- const patched = claudeCandidates.map((f) => (0, dev_patches_1.patchClaudeMd)(f, { dbName, identity })).filter((r) => r.patched);
154
- if (patched.length > 0) {
155
- info(colors.dim(`updated CLAUDE.md URL block in ${patched.length} file(s)`));
156
- }
157
- }
158
148
  // Always keep `.lt-dev/` (state, env bridge, ticket marker) out of git.
159
149
  if ((0, dev_patches_1.addToGitignore)(layout.root, '.lt-dev/')) {
160
150
  info(colors.dim('added `.lt-dev/` to .gitignore'));
@@ -231,11 +221,16 @@ const UpCommand = {
231
221
  // process). Free-checking a reused port would falsely abort a partial
232
222
  // restart of just the crashed half.
233
223
  const portsToCheck = [apiPortReused ? undefined : apiPort, appPortReused ? undefined : appPort].filter((p) => typeof p === 'number');
234
- const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
224
+ const probe = yield (0, dev_process_1.probePorts)(portsToCheck);
235
225
  for (const p of portsToCheck) {
236
- const r = snap.get(p);
237
- if (r)
238
- throw new Error(`Internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
226
+ if (!probe.bound.has(p))
227
+ continue;
228
+ const owner = probe.owners.get(p);
229
+ // The port is occupied either way; naming the occupant is a courtesy the
230
+ // platform may not be able to extend.
231
+ throw new Error(owner
232
+ ? `Internal port ${p} already in use by ${owner.command} (pid ${owner.pid}).`
233
+ : `Internal port ${p} already in use by an unidentified process.`);
239
234
  }
240
235
  // Reserve immediately so a concurrent `lt dev up` sees these as taken.
241
236
  const subdomainMap = {};
@@ -257,6 +252,26 @@ const UpCommand = {
257
252
  process.exit(1);
258
253
  return 'dev up: port in use';
259
254
  }
255
+ // The CLAUDE.md URL block is written HERE, after the internal ports are
256
+ // resolved, so it can name both addresses: the `*.localhost` name a browser
257
+ // opens and the loopback address a script must use. It used to run before
258
+ // allocation, where only the name existed — which is why an agent following
259
+ // the block had nothing but a hostname that does not resolve for Node on
260
+ // Windows. NEVER for a ticket worktree: that CLAUDE.md is git-tracked and
261
+ // would carry per-ticket URLs into a commit.
262
+ if (!ticket) {
263
+ const claudeCandidates = [
264
+ (0, path_1.join)(layout.root, 'CLAUDE.md'),
265
+ ...(layout.apiDir ? [(0, path_1.join)(layout.apiDir, 'CLAUDE.md')] : []),
266
+ ...(layout.appDir ? [(0, path_1.join)(layout.appDir, 'CLAUDE.md')] : []),
267
+ ];
268
+ const patched = claudeCandidates
269
+ .map((f) => (0, dev_patches_1.patchClaudeMd)(f, { apiPort, appPort, dbName, identity }))
270
+ .filter((r) => r.patched);
271
+ if (patched.length > 0) {
272
+ info(colors.dim(`updated CLAUDE.md URL block in ${patched.length} file(s)`));
273
+ }
274
+ }
260
275
  // ── Health-aware (re)start decision ──────────────────────────────────────
261
276
  // Probe the just-resolved ports so we can tell a still-serving component
262
277
  // from a crashed one (supervisor PID alive, port free). Only dead/crashed
@@ -269,18 +284,18 @@ const UpCommand = {
269
284
  // false-positive the `starting` state exists to prevent).
270
285
  const hasApi = Boolean(layout.apiDir && (0, fs_1.existsSync)((0, path_1.join)(layout.apiDir, 'package.json')) && apiPort);
271
286
  const hasApp = Boolean(layout.appDir && (0, fs_1.existsSync)((0, path_1.join)(layout.appDir, 'package.json')) && appPort);
272
- const healthSnap = yield (0, dev_process_1.listenSnapshot)([apiPort, appPort].filter((p) => typeof p === 'number'));
287
+ const healthProbe = yield (0, dev_process_1.probePorts)([apiPort, appPort].filter((p) => typeof p === 'number'));
273
288
  const apiHealth = hasApi
274
289
  ? (0, dev_state_1.classifyComponentHealth)({
275
290
  pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.api,
276
- portBound: !!apiPort && healthSnap.has(apiPort),
291
+ portBound: !!apiPort && healthProbe.bound.has(apiPort),
277
292
  startedAt: existingSession === null || existingSession === void 0 ? void 0 : existingSession.startedAt,
278
293
  })
279
294
  : undefined;
280
295
  const appHealth = hasApp
281
296
  ? (0, dev_state_1.classifyComponentHealth)({
282
297
  pid: existingSession === null || existingSession === void 0 ? void 0 : existingSession.pids.app,
283
- portBound: !!appPort && healthSnap.has(appPort),
298
+ portBound: !!appPort && healthProbe.bound.has(appPort),
284
299
  startedAt: existingSession === null || existingSession === void 0 ? void 0 : existingSession.startedAt,
285
300
  })
286
301
  : undefined;
@@ -374,9 +389,19 @@ const UpCommand = {
374
389
  const reclaimPort = (prevPid, port, health) => __awaiter(void 0, void 0, void 0, function* () {
375
390
  if (health === 'crashed' && prevPid)
376
391
  yield (0, dev_process_1.terminateProcessGroup)(prevPid);
377
- const bound = port ? healthSnap.get(port) : undefined;
378
- if (bound === null || bound === void 0 ? void 0 : bound.pid)
379
- yield (0, dev_process_1.terminateProcessGroup)(bound.pid);
392
+ if (!port || !healthProbe.bound.has(port))
393
+ return;
394
+ const owner = healthProbe.owners.get(port);
395
+ if (owner === null || owner === void 0 ? void 0 : owner.pid) {
396
+ yield (0, dev_process_1.terminateProcessGroup)(owner.pid);
397
+ return;
398
+ }
399
+ // Occupied, but the platform could not name the occupant — so there is
400
+ // nothing to terminate. Saying so beats respawning onto a taken port and
401
+ // letting the component fail with an address-in-use nobody connects to this.
402
+ warning(`port ${port} is in use and the occupant could not be identified` +
403
+ `${healthProbe.ownersUnavailable ? ' (no lsof/netstat available)' : ''} — ` +
404
+ 'not reclaiming it; the component may fail to bind.');
380
405
  });
381
406
  if (hasApi && layout.apiDir && apiPort) {
382
407
  if (isKeepable(apiHealth)) {
@@ -454,6 +479,19 @@ const UpCommand = {
454
479
  // grace window indefinitely.
455
480
  const startedAt = started.length > 0 ? new Date().toISOString() : ((_h = existingSession === null || existingSession === void 0 ? void 0 : existingSession.startedAt) !== null && _h !== void 0 ? _h : new Date().toISOString());
456
481
  (0, dev_state_1.saveSession)(layout.root, { pids, startedAt });
482
+ // A component that dies right after its start used to be reported as
483
+ // "Started": the spawn succeeded, and nothing looked again. Watch the fresh
484
+ // pids for a moment and show what the log says (or that it is empty).
485
+ const earlyExits = yield (0, dev_log_tail_1.findEarlyExits)(started.map((name) => ({ name, pid: pids[name] })), { budgetMs: 3000, isAlive: dev_state_1.isPidAlive });
486
+ if (earlyExits.length > 0) {
487
+ for (const name of earlyExits) {
488
+ error(`${name} exited right after its start (pid ${pids[name]}).`);
489
+ (0, dev_log_tail_1.describeLog)((0, dev_log_tail_1.diagnoseLog)((0, path_1.join)(layout.root, '.lt-dev', `${name}.log`))).forEach((l) => info(l));
490
+ }
491
+ if (!parameters.options.fromGluegunMenu)
492
+ process.exit(1);
493
+ return `dev up: ${earlyExits.join('+')} exited on start`;
494
+ }
457
495
  // Write the ENV bridge so external tools (Playwright, IDE test runners,
458
496
  // custom shell scripts) can pick up the URLs without inheriting our shell.
459
497
  const bridgePath = (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName);
@@ -664,7 +664,7 @@ class Server {
664
664
  if (!experimental) {
665
665
  // Generate README
666
666
  yield template.generate({
667
- props: { description, name },
667
+ props: { description, name, vendor: frameworkMode === 'vendor' },
668
668
  target: `${dest}/README.md`,
669
669
  template: 'nest-server-starter/README.md.ejs',
670
670
  });
@@ -36,10 +36,11 @@ exports.writeCaddyfile = writeCaddyfile;
36
36
  * Lifecycle is owned by `lt dev install` (one-time setup) and
37
37
  * `lt dev up`/`lt dev down` (per-project block management).
38
38
  */
39
- const child_process_1 = require("child_process");
40
39
  const fs_1 = require("fs");
41
40
  const os_1 = require("os");
42
41
  const path_1 = require("path");
42
+ const dev_process_1 = require("./dev-process");
43
+ const platform_1 = require("./platform");
43
44
  const CADDYFILE_PATH = process.env.LT_DEV_CADDYFILE || (0, path_1.join)((0, os_1.homedir)(), '.lenneTech', 'Caddyfile');
44
45
  const HEADER = '# Managed by `lt dev`. Per-project blocks are bounded by `# >>> lt-dev:<slug> >>>` markers.';
45
46
  /** Detect whether `caddy` is on PATH. */
@@ -49,16 +50,22 @@ function caddyAvailable() {
49
50
  return result.ok;
50
51
  });
51
52
  }
52
- /** Detect whether the Caddy admin endpoint is reachable (i.e. a daemon is running). */
53
+ /**
54
+ * Detect whether the Caddy admin endpoint is reachable (i.e. a daemon is running).
55
+ *
56
+ * This asked `curl -fsS -o /dev/null`, and on Windows that reported a running
57
+ * Caddy as down: `/dev/null` is an ordinary file path there, so curl completed
58
+ * the request, received the 226-byte answer, failed to WRITE it, and exited 23.
59
+ * The exit code was all this function looked at. Measured on the laptop against a
60
+ * Caddy that was listening on :2019 and answering 200.
61
+ *
62
+ * `127.0.0.1` rather than `localhost` for consistency with the reverse-proxy
63
+ * upstreams (see the note above `renderProjectBlock`) — not because `localhost`
64
+ * was the fault here; it was measured to resolve correctly on that machine.
65
+ */
53
66
  function caddyDaemonRunning() {
54
67
  return __awaiter(this, void 0, void 0, function* () {
55
- return new Promise((resolve) => {
56
- const child = (0, child_process_1.spawn)('curl', ['-fsS', '-o', '/dev/null', 'http://localhost:2019/config/'], {
57
- stdio: ['ignore', 'ignore', 'ignore'],
58
- });
59
- child.on('error', () => resolve(false));
60
- child.on('close', (code) => resolve(code === 0));
61
- });
68
+ return (yield (0, dev_process_1.httpStatus)('http://127.0.0.1:2019/config/', 2000)) !== null;
62
69
  });
63
70
  }
64
71
  /** Read the current Caddyfile (or empty string). */
@@ -162,7 +169,7 @@ function writeCaddyfile(content) {
162
169
  function runCaddy(args) {
163
170
  return new Promise((resolve) => {
164
171
  var _a, _b;
165
- const child = (0, child_process_1.spawn)('caddy', args, { stdio: ['ignore', 'pipe', 'pipe'] });
172
+ const child = (0, platform_1.spawnCmd)('caddy', args, { stdio: ['ignore', 'pipe', 'pipe'] });
166
173
  let stdout = '';
167
174
  let stderr = '';
168
175
  let errored = false;
@@ -84,7 +84,7 @@ function syncCheckTemplate(options) {
84
84
  if (spawned.length > 0) {
85
85
  throw new Error(`${ref}: the template starts sibling script(s) it does not ship: ${spawned.join(', ')}. ` +
86
86
  'resolveCopySet only follows imports, so these would be missing in every generated project. ' +
87
- 'Add them to the wrapper\'s import closure, ship them deliberately, or remove the call upstream.');
87
+ "Add them to the wrapper's import closure, ship them deliberately, or remove the call upstream.");
88
88
  }
89
89
  const pin = {
90
90
  commit,
@@ -97,6 +97,13 @@ function writeEnvBridge(projectRoot, devEnv, dbName, fileName = '.env') {
97
97
  // Legacy aliases — see dev-env.ts for the rationale.
98
98
  'API_URL',
99
99
  'SITE_URL',
100
+ // The loopback addresses. An external runner that RESOLVES a URL from Node —
101
+ // an API helper, a seeding script, a readiness check — needs these: on Windows
102
+ // `*.localhost` does not resolve for Node at all, only for browsers. The
103
+ // public names above stay, because browser-facing values (a Playwright
104
+ // `baseURL`, a link a developer opens) must keep them. See `dev-env.ts#internalUrl`.
105
+ 'LT_DEV_API_INTERNAL_URL',
106
+ 'LT_DEV_APP_INTERNAL_URL',
100
107
  ];
101
108
  for (const key of exported) {
102
109
  const v = devEnv.app.env[key];
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildDevEnv = buildDevEnv;
4
+ exports.internalUrl = internalUrl;
4
5
  /**
5
6
  * Build environment variables for `lt dev up`.
6
7
  *
@@ -41,13 +42,16 @@ function buildDevEnv(input) {
41
42
  const appSub = identity.subdomains.app;
42
43
  const apiUrl = apiSub ? `https://${apiSub.hostname}` : '';
43
44
  const appUrl = appSub ? `https://${appSub.hostname}` : '';
45
+ // Same two services, addressed the way Node can reach them. See `internalUrl`.
46
+ const apiInternal = internalUrl(apiInternalPort);
47
+ const appInternal = internalUrl(appInternalPort);
44
48
  const caPath = (0, dev_env_bridge_1.detectCaddyRootCa)();
45
- const sharedKeys = Object.assign(Object.assign(Object.assign(Object.assign({
49
+ const sharedKeys = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({
46
50
  // Marks the API + App processes as running under `lt dev`. Consumed by the
47
51
  // backend to relax dev-only behaviour (rate limiting, Better-Auth
48
52
  // user-cache) so E2E suites run without a separate VITEST/PLAYWRIGHT flag.
49
53
  // (Also written to the .lt-dev/.env bridge for external test runners.)
50
- LT_DEV_ACTIVE: 'true' }, (apiUrl ? { BASE_URL: apiUrl, NSC__BASE_URL: apiUrl } : {})), (appUrl ? { APP_URL: appUrl, NSC__APP_URL: appUrl } : {})), (dbName ? { DATABASE_URL: buildPostgresUrl(dbName), NSC__MONGOOSE__URI: `mongodb://127.0.0.1/${dbName}` } : {})), (caPath ? { NODE_EXTRA_CA_CERTS: caPath } : {}));
54
+ LT_DEV_ACTIVE: 'true' }, (apiInternal ? { LT_DEV_API_INTERNAL_URL: apiInternal } : {})), (appInternal ? { LT_DEV_APP_INTERNAL_URL: appInternal } : {})), (apiUrl ? { BASE_URL: apiUrl, NSC__BASE_URL: apiUrl } : {})), (appUrl ? { APP_URL: appUrl, NSC__APP_URL: appUrl } : {})), (dbName ? { DATABASE_URL: buildPostgresUrl(dbName), NSC__MONGOOSE__URI: `mongodb://127.0.0.1/${dbName}` } : {})), (caPath ? { NODE_EXTRA_CA_CERTS: caPath } : {}));
51
55
  return {
52
56
  api: {
53
57
  env: Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
@@ -59,10 +63,10 @@ function buildDevEnv(input) {
59
63
  internalPort: apiInternalPort,
60
64
  },
61
65
  app: {
62
- env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
66
+ env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
63
67
  // See API note above: pin the dev server to IPv4 so Caddy's
64
68
  // `127.0.0.1` upstream is unambiguous.
65
- HOST: '127.0.0.1', NITRO_HOST: '127.0.0.1' }), (apiUrl ? { API_URL: apiUrl, NUXT_API_URL: apiUrl, NUXT_PUBLIC_API_URL: apiUrl } : {})), (appUrl ? { NUXT_PUBLIC_SITE_URL: appUrl, SITE_URL: appUrl } : {})), {
69
+ HOST: '127.0.0.1', NITRO_HOST: '127.0.0.1' }), (apiUrl ? { API_URL: apiUrl, NUXT_PUBLIC_API_URL: apiUrl } : {})), (apiInternal || apiUrl ? { NUXT_API_URL: apiInternal || apiUrl } : {})), (appUrl ? { NUXT_PUBLIC_SITE_URL: appUrl, SITE_URL: appUrl } : {})), {
66
70
  // Vite-API-Proxy is OFF by default in lt dev mode — Caddy serves
67
71
  // both subdomains under HTTPS with shared cookie domain, so
68
72
  // same-origin trickery is no longer required.
@@ -75,6 +79,42 @@ function buildDevEnv(input) {
75
79
  },
76
80
  };
77
81
  }
82
+ /**
83
+ * The loopback address of a component, for anything that RESOLVES a name.
84
+ *
85
+ * `lt dev` is URL-first: a project is reached at `https://<slug>.localhost`, and
86
+ * Caddy proxies that to an opaque internal port. That promise holds for
87
+ * **browsers**. It does not hold for Node: on Windows `*.localhost` subdomains do
88
+ * not resolve at all (`dns.lookup('api.demo.localhost')` → ENOTFOUND, and `curl`
89
+ * agrees), while Chromium resolves them internally without asking a resolver.
90
+ *
91
+ * So the question at every call site is not "which URL" but **"who reads it"**:
92
+ *
93
+ * | | example | address to use |
94
+ * |---|---|---|
95
+ * | a browser resolves it | Playwright `baseURL`, a printed link | the public name |
96
+ * | Node resolves it | readiness probes, SSR fetches, a proxy target | **this** |
97
+ * | another binary resolves it | `cloudflared` | **this** |
98
+ * | nobody resolves it — it is COMPARED | `APP_URL` (CORS / `trustedOrigins`), the Caddy vhost matcher, the tunnel `Host:` header | the public name, untouched |
99
+ *
100
+ * That last row is why "rewrite every internal URL to 127.0.0.1" would be wrong:
101
+ * `APP_URL` is matched as a string against the `Origin` header a browser sends,
102
+ * and the browser arrives from `https://<slug>.localhost`. Rewriting it would
103
+ * break every login — on the platform where only a health probe was broken before.
104
+ *
105
+ * **Loopback on every platform, not only on Windows.** 127.0.0.1 works everywhere,
106
+ * so there is no branch to get wrong, and the path Windows depends on is the one
107
+ * macOS exercises daily. A branch only the other platform runs is an unchecked
108
+ * branch.
109
+ *
110
+ * One consequence to keep in mind: this goes PAST Caddy. For "is the component
111
+ * alive?" that is an advantage — it measures the component, not the proxy. For
112
+ * "does the routing work?" it is the wrong question, and that one belongs to
113
+ * `lt dev doctor` over the public name.
114
+ */
115
+ function internalUrl(port) {
116
+ return port ? `http://127.0.0.1:${port}` : '';
117
+ }
78
118
  /** Postgres convenience URL — used by Postgres-based projects (e.g. nest-base). */
79
119
  function buildPostgresUrl(dbName) {
80
120
  return `postgresql://${dbName}:${dbName}@localhost:5432/${dbName}`;
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.describeLog = describeLog;
13
+ exports.diagnoseLog = diagnoseLog;
14
+ exports.findEarlyExits = findEarlyExits;
15
+ exports.isSilentLog = isSilentLog;
16
+ exports.tailLines = tailLines;
17
+ /**
18
+ * What a component's log can tell someone whose component just died.
19
+ *
20
+ * Two silent failures made this necessary, both seen on a Windows laptop:
21
+ * `lt dev up` reported success for an API that was dead seconds later, and its
22
+ * log file existed, was even rotated, but stayed at 0 bytes, so nobody looked in
23
+ * the extra console window that held the actual error. A diagnosis therefore has
24
+ * to tell "the log says X" apart from "the log is empty", and say the latter out
25
+ * loud instead of printing nothing.
26
+ */
27
+ const fs_1 = require("fs");
28
+ /**
29
+ * The lines to print under "<name> is not running". Never an empty list: an
30
+ * empty or missing log is itself the finding.
31
+ */
32
+ function describeLog(d) {
33
+ if (d.state === 'missing')
34
+ return [`No log at ${d.file}.`];
35
+ if (d.state === 'empty') {
36
+ return [`${d.file} is EMPTY — the output was not captured, so the reason is not in the log.`];
37
+ }
38
+ return [`Last lines of ${d.file}:`, ...d.tail.map((l) => ` ${l}`)];
39
+ }
40
+ /** Read `file` and keep its last `lines` non-empty lines. */
41
+ function diagnoseLog(file, lines = 20) {
42
+ if (!(0, fs_1.existsSync)(file))
43
+ return { file, size: -1, state: 'missing', tail: [] };
44
+ const size = (0, fs_1.statSync)(file).size;
45
+ if (size === 0)
46
+ return { file, size, state: 'empty', tail: [] };
47
+ return { file, size, state: 'ok', tail: tailLines((0, fs_1.readFileSync)(file, 'utf8'), lines) };
48
+ }
49
+ /**
50
+ * Components whose pid is gone within `budgetMs` of their start.
51
+ *
52
+ * Catches what dies at once: a missing script, a package manager that cannot
53
+ * start, a crash before any watcher takes over. A crash under nodemon keeps the
54
+ * supervisor alive and is `lt dev status`'s job (`crashed` after the grace window).
55
+ */
56
+ function findEarlyExits(components, opts) {
57
+ return __awaiter(this, void 0, void 0, function* () {
58
+ var _a;
59
+ const sleep = (_a = opts.sleep) !== null && _a !== void 0 ? _a : ((ms) => new Promise((r) => setTimeout(r, ms)));
60
+ const watched = components.filter((c) => typeof c.pid === 'number');
61
+ const dead = new Set();
62
+ const deadline = Date.now() + opts.budgetMs;
63
+ do {
64
+ for (const c of watched)
65
+ if (!dead.has(c.name) && !opts.isAlive(c.pid))
66
+ dead.add(c.name);
67
+ if (dead.size === watched.length)
68
+ break;
69
+ yield sleep(250);
70
+ } while (Date.now() < deadline);
71
+ return watched.filter((c) => dead.has(c.name)).map((c) => c.name);
72
+ });
73
+ }
74
+ /**
75
+ * True when a component has been up for `minAgeMs` and its log is still empty.
76
+ * A booting app writes within seconds (nuxt/nest print a banner), so an empty
77
+ * log after that means output is going somewhere else, not that nothing happened.
78
+ */
79
+ function isSilentLog(d, startedAt, now, minAgeMs = 15000) {
80
+ if (d.state !== 'empty' || !startedAt)
81
+ return false;
82
+ const age = now - new Date(startedAt).getTime();
83
+ return Number.isFinite(age) && age >= minAgeMs;
84
+ }
85
+ /** Last `n` non-empty lines of `content`, oldest first. */
86
+ function tailLines(content, n) {
87
+ return content
88
+ .split(/\r?\n/)
89
+ .filter((l) => l.trim().length > 0)
90
+ .slice(-n);
91
+ }