@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
@@ -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.
@@ -23,8 +23,10 @@ const dev_ticket_1 = require("../../lib/dev-ticket");
23
23
  * App mode (default) runs the Playwright suite against a fully ISOLATED test
24
24
  * stack (own URLs / ports / Caddy block / dedicated `<…>-test` database) that
25
25
  * runs parallel to — and never touches — the developer's `lt dev up` session.
26
- * Playwright's global-setup resets that dedicated DB once before the first test.
27
- * The stack is torn down automatically when the run finishes (residue-free).
26
+ * That DB outlives the run, so pending migrations are applied to it before the
27
+ * API starts; a project's Playwright global-setup may reset it on top (the CLI
28
+ * does not). The stack is torn down automatically when the run finishes
29
+ * (residue-free).
28
30
  *
29
31
  * API mode (`--api`) runs the standalone API test suite (`pnpm test:e2e` in the
30
32
  * API), which already isolates itself on its own DB — no stack is brought up.
@@ -225,7 +227,7 @@ const TestCommand = {
225
227
  try {
226
228
  const ctx = yield (0, dev_test_session_1.bringUpTestSession)(layout, identity, log, { devDbName });
227
229
  const env = Object.assign(Object.assign(Object.assign(Object.assign({}, process.env), readBridgeEnv(layout.root)), {
228
- // Playwright global-setup resets THIS db (allow-listed) before the suite.
230
+ // THIS db, for a project whose global-setup resets or cleans it (allow-listed).
229
231
  MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` }), (ctx.apiLogPath ? { NEST_SERVER_LOG: ctx.apiLogPath } : {}));
230
232
  if (debug) {
231
233
  env.PWDEBUG = '1';
@@ -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);
@@ -488,7 +488,7 @@ const NewCommand = {
488
488
  yield system.run(`cd ${projectDir} && git init --initial-branch=dev`);
489
489
  }
490
490
  catch (err) {
491
- error(`Failed to initialize git: ${err.message}`);
491
+ ngBaseSpinner.fail(`Failed to initialize git: ${err.message}`);
492
492
  (0, fail_run_1.failRun)(toolbox);
493
493
  return;
494
494
  }
@@ -498,7 +498,7 @@ const NewCommand = {
498
498
  yield system.run(`cd ${projectDir} && git remote add origin ${gitLink}`);
499
499
  }
500
500
  catch (err) {
501
- error(`Failed to add remote: ${err.message}`);
501
+ ngBaseSpinner.fail(`Failed to add remote: ${err.message}`);
502
502
  (0, fail_run_1.failRun)(toolbox);
503
503
  return;
504
504
  }
@@ -526,7 +526,7 @@ const NewCommand = {
526
526
  });
527
527
  }
528
528
  if (!frontendResult.success) {
529
- error(`Failed to set up ${frontend} frontend: ${frontendResult.path}`);
529
+ ngBaseSpinner.fail(`Failed to set up ${frontend} frontend: ${frontendResult.path}`);
530
530
  (0, fail_run_1.failRun)(toolbox);
531
531
  return;
532
532
  }
@@ -774,6 +774,15 @@ const NewCommand = {
774
774
  // For tests
775
775
  return `new workspace ${projectDir} with ${name}`;
776
776
  }
777
+ else {
778
+ // The frontend setup reported success but left no `projects/app`. Without this
779
+ // branch the command fell off the end here: no message, no failing exit code,
780
+ // and `ngBaseSpinner` still spinning — which keeps the event loop alive (see
781
+ // the spinner note in CLAUDE.md), so the process never returned at all.
782
+ ngBaseSpinner.fail(`${frontend} was set up but "${projectDir}/projects/app" does not exist — cannot continue`);
783
+ (0, fail_run_1.failRun)(toolbox);
784
+ return;
785
+ }
777
786
  }),
778
787
  };
779
788
  exports.default = NewCommand;
@@ -129,7 +129,10 @@ const NewCommand = {
129
129
  const mergeBaseSpin = spin(`Get merge ${base}`);
130
130
  const mergeBase = yield git.getMergeBase(base);
131
131
  if (!mergeBase) {
132
- error('No merge base found!');
132
+ // `fail` rather than `error` + `return`: a running spinner holds the event
133
+ // loop open (ora resumes stdin and keeps a render interval), so the old
134
+ // shape did not just print and leave — the command never exited at all.
135
+ mergeBaseSpin.fail(`No merge base found with ${base}!`);
133
136
  return;
134
137
  }
135
138
  mergeBaseSpin.succeed();
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.ApiMode = void 0;
13
13
  const glob_1 = require("glob");
14
14
  const path_1 = require("path");
15
+ const config_env_graphql_1 = require("../lib/config-env-graphql");
15
16
  /**
16
17
  * API Mode processing for nest-server-starter template
17
18
  *
@@ -46,11 +47,18 @@ class ApiMode {
46
47
  // REST mode: remove graphql regions, keep rest regions
47
48
  yield this.removeMode(projectPath, manifest, 'graphql', 'rest');
48
49
  yield this.modifyConfigEnvForRest(projectPath);
50
+ this.assertGraphQlDisabled(projectPath);
49
51
  }
50
52
  else if (mode === 'GraphQL') {
51
53
  // GraphQL mode: remove rest regions, keep graphql regions
52
54
  yield this.removeMode(projectPath, manifest, 'rest', 'graphql');
53
55
  }
56
+ // Both conversions above must leave no marker behind. Verified rather than
57
+ // assumed: a strip that quietly does nothing reports success just as loudly as
58
+ // one that worked (see assertNoMarkersRemain).
59
+ if (mode !== 'Both') {
60
+ this.assertNoMarkersRemain(projectPath, mode === 'Rest' ? 'graphql' : 'rest');
61
+ }
54
62
  // Remove manifest and strip-markers script
55
63
  this.filesystem.remove(manifestPath);
56
64
  this.filesystem.remove((0, path_1.join)(projectPath, 'scripts', 'strip-api-mode-markers.mjs'));
@@ -167,6 +175,67 @@ class ApiMode {
167
175
  this.cleanOrphanImports(projectPath);
168
176
  });
169
177
  }
178
+ /**
179
+ * Final check of the REST conversion: every environment block in `config.env.ts`
180
+ * must disable GraphQL.
181
+ *
182
+ * `CoreModule.forRoot` reads a missing `graphQl` as ENABLED, so a block without it
183
+ * yields a project that fails at START time with `Cannot determine a GraphQL output
184
+ * type for the "arguments"` — long after the generator reported success. Every step
185
+ * before this one REPLACES an existing property; none of them adds a missing one, so
186
+ * nothing guaranteed the outcome.
187
+ *
188
+ * Repairs what it can (insert the switch) and throws when a block still lacks it,
189
+ * rather than handing over a project that cannot boot.
190
+ */
191
+ assertGraphQlDisabled(projectPath) {
192
+ const configPath = (0, path_1.join)(projectPath, 'src', 'config.env.ts');
193
+ const content = this.filesystem.read(configPath);
194
+ if (!content) {
195
+ return;
196
+ }
197
+ const { added, content: repaired } = (0, config_env_graphql_1.disableGraphQlInEveryEnvBlock)(content);
198
+ if (added.length > 0) {
199
+ this.filesystem.write(configPath, repaired);
200
+ }
201
+ const offenders = (0, config_env_graphql_1.envBlocksWithoutGraphQlDisabled)(repaired);
202
+ if (offenders.length > 0) {
203
+ throw new Error(`REST conversion incomplete: src/config.env.ts has no \`graphQl: false\` in ${offenders.join(', ')}. ` +
204
+ 'The framework treats a missing switch as GraphQL ENABLED, and the server would fail to start. ' +
205
+ 'Add it to those environment blocks and re-run.');
206
+ }
207
+ }
208
+ /**
209
+ * Refuse to hand over a project that still carries markers of the removed mode.
210
+ *
211
+ * A leftover `// #region graphql` announces a mode the project does not have — and,
212
+ * worse, means the region CONTENT is still there: resolvers, graphql-only wiring,
213
+ * e2e specs. That is what a silent no-op looks like from the outside, and the strip
214
+ * used to be exactly that on Windows (fixed in the glob helper above): it reported
215
+ * success while touching nothing.
216
+ *
217
+ * Cheap, and it fails at generation time instead of in the user's editor.
218
+ */
219
+ assertNoMarkersRemain(projectPath, removedMarker) {
220
+ const marked = [];
221
+ for (const dir of ['src', 'tests']) {
222
+ const base = (0, path_1.join)(projectPath, dir);
223
+ if (!this.filesystem.exists(base)) {
224
+ continue;
225
+ }
226
+ for (const file of this.globFiles(base, '**/*.ts')) {
227
+ const content = this.filesystem.read(file);
228
+ if ((content === null || content === void 0 ? void 0 : content.includes(`// #region ${removedMarker}`)) || (content === null || content === void 0 ? void 0 : content.includes(`// #endregion ${removedMarker}`))) {
229
+ marked.push(`${dir}/${(0, path_1.basename)(file)}`);
230
+ }
231
+ }
232
+ }
233
+ if (marked.length > 0) {
234
+ throw new Error(`API-mode conversion incomplete: ${marked.length} file(s) still carry \`// #region ${removedMarker}\` ` +
235
+ `markers (${marked.slice(0, 5).join(', ')}${marked.length > 5 ? ', …' : ''}). ` +
236
+ 'The region content was not removed, so this project still contains code of a mode it does not have.');
237
+ }
238
+ }
170
239
  /**
171
240
  * Resolve a POSIX-style glob pattern against `baseDir` and return the
172
241
  * matching files as absolute, OS-native paths.
@@ -12,7 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.FrontendHelper = void 0;
13
13
  const check_freshness_hooks_1 = require("../lib/check-freshness-hooks");
14
14
  const markdown_table_1 = require("../lib/markdown-table");
15
- const strip_comments_1 = require("../lib/strip-comments");
15
+ const module_specifiers_1 = require("../lib/module-specifiers");
16
16
  const strip_vendor_schema_augmentation_1 = require("../lib/strip-vendor-schema-augmentation");
17
17
  const vendor_claude_md_1 = require("../lib/vendor-claude-md");
18
18
  /**
@@ -399,7 +399,7 @@ class FrontendHelper {
399
399
  }
400
400
  }
401
401
  // ── Post-conversion verification ─────────────────────────────────────
402
- const stale = this.findStaleFrontendImports(dest, /from\s+['"]\..*\/core['"]/);
402
+ const stale = this.findStaleFrontendImports(dest, module_specifiers_1.isRelativeCoreImport);
403
403
  if (stale.length > 0) {
404
404
  const { print } = this.toolbox;
405
405
  print.warning(`${stale.length} file(s) still contain relative core imports after npm conversion:`);
@@ -716,8 +716,7 @@ class FrontendHelper {
716
716
  ].join('\n'));
717
717
  }
718
718
  // ── Post-conversion verification ─────────────────────────────────────
719
- // Only match actual import/from statements, not comments or strings
720
- const staleImports = this.findStaleFrontendImports(dest, /(?:^|\s)(?:import|from)\s+['"][^'"]*@lenne\.tech\/nuxt-extensions/m, 'app/core/');
719
+ const staleImports = this.findStaleFrontendImports(dest, (specifier) => (0, module_specifiers_1.isPackageImport)(specifier, '@lenne.tech/nuxt-extensions'), 'app/core/');
721
720
  if (staleImports.length > 0) {
722
721
  const { print } = this.toolbox;
723
722
  print.warning(`${staleImports.length} file(s) still contain '@lenne.tech/nuxt-extensions' imports after vendor conversion:`);
@@ -826,20 +825,20 @@ class FrontendHelper {
826
825
  /**
827
826
  * Scan consumer files for stale imports matching a pattern.
828
827
  */
829
- findStaleFrontendImports(appDir, needle, skipPathContaining) {
828
+ findStaleFrontendImports(appDir, matches, skipPathContaining) {
830
829
  const { filesystem } = this.toolbox;
831
830
  const allFiles = this.walkConsumerFiles(appDir);
832
831
  const stale = [];
833
832
  for (const absFile of allFiles) {
834
833
  if (skipPathContaining && absFile.includes(skipPathContaining))
835
834
  continue;
836
- // Strip comments first — a docblock that DOCUMENTS the conversion legitimately quotes the
837
- // very import syntax this looks for, and would otherwise be reported as a file the user has
838
- // to fix by hand. Same false-positive class that hit the backend detector on
839
- // nest-server-starter's bootstrap-diagnostics.spec.ts.
840
- const content = (0, strip_comments_1.stripComments)(filesystem.read(absFile) || '');
841
- const matches = typeof needle === 'string' ? content.includes(needle) : needle.test(content);
842
- if (matches) {
835
+ // Ask the parser for the file's module specifiers instead of searching its text.
836
+ // A docblock that DOCUMENTS the conversion quotes the very import syntax a text
837
+ // search looks for, and blanking comments first does not hold either — see
838
+ // src/lib/module-specifiers.ts. A comment is not part of the AST, so the whole
839
+ // false-positive class is gone rather than worked around.
840
+ const specifiers = (0, module_specifiers_1.fileImportedSpecifiers)(absFile, (path) => filesystem.read(path) || undefined);
841
+ if (specifiers.some(matches)) {
843
842
  stale.push(absFile.replace(`${appDir}/`, ''));
844
843
  }
845
844
  }