@lenne.tech/cli 1.47.0 → 1.49.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 (34) hide show
  1. package/build/commands/dev/test.js +5 -3
  2. package/build/commands/fullstack/init.js +12 -3
  3. package/build/commands/git/force-pull.js +11 -2
  4. package/build/commands/git/rebase.js +11 -1
  5. package/build/commands/git/reset.js +36 -4
  6. package/build/commands/git/squash.js +4 -1
  7. package/build/commands/git/update.js +42 -6
  8. package/build/extensions/api-mode.js +101 -8
  9. package/build/extensions/frontend-helper.js +11 -12
  10. package/build/extensions/git.js +23 -25
  11. package/build/extensions/server.js +66 -49
  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 +92 -14
  16. package/build/lib/dev-package-manager.js +13 -9
  17. package/build/lib/dev-process.js +66 -30
  18. package/build/lib/dev-state.js +13 -2
  19. package/build/lib/dev-test-session.js +166 -55
  20. package/build/lib/fail-run.js +11 -0
  21. package/build/lib/git-env.js +46 -0
  22. package/build/lib/heal-oxlintrc.js +20 -3
  23. package/build/lib/heal-vendor-migrate-store.js +4 -3
  24. package/build/lib/migrate-scripts.js +69 -0
  25. package/build/lib/module-specifiers.js +144 -0
  26. package/build/lib/spinner-lifetime.js +245 -0
  27. package/build/lib/strip-vendor-schema-augmentation.js +13 -2
  28. package/build/lib/vendor-claude-md.js +9 -6
  29. package/build/lib/workspace-integration.js +12 -2
  30. package/build/templates/check/check.mjs +59 -9
  31. package/build/templates/check/lt-monorepo-pin.json +4 -4
  32. package/docs/commands.md +16 -12
  33. package/package.json +1 -1
  34. package/build/lib/strip-comments.js +0 -78
@@ -74,16 +74,20 @@ function buildCommand(bin, name) {
74
74
  },
75
75
  };
76
76
  }
77
+ /**
78
+ * Manager behind an override like `LT_PM_BIN`.
79
+ *
80
+ * Reads the file NAME, split on both separators and stripped of a Windows launcher
81
+ * extension: there the binary is `C:\\…\\pnpm.cmd`, which neither `endsWith('pnpm')`
82
+ * nor `includes('/pnpm')` matched — the override then resolved to `unknown` and the
83
+ * caller drove the wrong manager.
84
+ */
77
85
  function inferNameFromBin(bin) {
78
- const lower = bin.toLowerCase();
79
- if (lower.endsWith('pnpm') || lower.includes('/pnpm')) {
80
- return 'pnpm';
81
- }
82
- if (lower.endsWith('yarn') || lower.includes('/yarn')) {
83
- return 'yarn';
84
- }
85
- if (lower.endsWith('npm') || lower.includes('/npm')) {
86
- return 'npm';
86
+ var _a;
87
+ const file = (_a = bin.toLowerCase().split(/[\\/]/).pop()) !== null && _a !== void 0 ? _a : '';
88
+ const name = file.replace(/\.(bat|cmd|exe|ps1)$/, '');
89
+ if (name === 'pnpm' || name === 'yarn' || name === 'npm') {
90
+ return name;
87
91
  }
88
92
  return 'unknown';
89
93
  }
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.checkPortInUse = checkPortInUse;
13
+ exports.detachedSpawnCommand = detachedSpawnCommand;
13
14
  exports.killProcessGroup = killProcessGroup;
14
15
  exports.listenSnapshot = listenSnapshot;
15
16
  exports.rotateLogFile = rotateLogFile;
@@ -59,6 +60,40 @@ function checkPortInUse(port) {
59
60
  });
60
61
  });
61
62
  }
63
+ /**
64
+ * How a detached child is actually launched on this platform.
65
+ *
66
+ * POSIX raises the soft file-descriptor limit before exec-ing the real command.
67
+ * macOS's default soft RLIMIT_NOFILE is 256 (launchd/system default), inherited
68
+ * by the terminal that runs `lt dev up` and therefore by these detached children
69
+ * — it is NOT a consequence of the lt-dev LaunchAgent (which runs only Caddy).
70
+ * The dev file-watcher (nest/nuxt → chokidar) exhausts a soft-256 limit on a
71
+ * monorepo → intermittent "EMFILE: too many open files, watch" crashes on boot
72
+ * that force a manual `lt dev up`. Hence `sh -c "ulimit …; exec …"`:
73
+ * - `exec` replaces the shell IN PLACE → the recorded PID and the detached
74
+ * process group are still the real process (PID tracking + group-kill in
75
+ * `terminateProcessGroup` keep working).
76
+ * - `"$0" "$@"` pass cmd + args verbatim — no shell-quoting / injection.
77
+ * - the cascade tries a high limit first, falling back on machines with a
78
+ * lower `kern.maxfilesperproc`; `2>/dev/null` keeps it best-effort.
79
+ *
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.
83
+ *
84
+ * Note for the POSIX path: because `spawn('/bin/sh', …)` almost always succeeds,
85
+ * a bogus `cmd` does not surface as `pid === undefined` — the inner `exec` fails
86
+ * (exit 127) a few ms later. Callers briefly record a live-then-dead PID, which
87
+ * `classifyComponentHealth` reaps as `dead`/`crashed` on the next status/up. We
88
+ * deliberately don't watch for that here: a detached, unref'd child's 127 exit is
89
+ * racy to observe, and callers must be self-correcting against real crashes anyway.
90
+ */
91
+ function detachedSpawnCommand(cmd, args, platform = process.platform) {
92
+ if (platform === 'win32')
93
+ return { args, command: cmd };
94
+ const raiseFdLimit = 'ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true';
95
+ return { args: ['-c', `${raiseFdLimit}; exec "$0" "$@"`, cmd, ...args], command: '/bin/sh' };
96
+ }
62
97
  /** Send SIGTERM to a detached process group; falls back to single-PID kill. */
63
98
  function killProcessGroup(pid) {
64
99
  if (!(0, dev_state_1.isValidPid)(pid))
@@ -215,6 +250,8 @@ function runChildToFile(cmd, args, opts) {
215
250
  * so closing prevents fd leaks and avoids racing-write artifacts on
216
251
  * filesystems where O_APPEND is not atomic.
217
252
  *
253
+ * See `detachedSpawnCommand` for how the child is launched per platform.
254
+ *
218
255
  * Returns the child PID, or undefined if spawn failed.
219
256
  */
220
257
  function spawnDetached(cmd, args, opts) {
@@ -223,32 +260,22 @@ function spawnDetached(cmd, args, opts) {
223
260
  const out = (0, fs_1.openSync)(opts.logFile, 'a');
224
261
  let child;
225
262
  try {
226
- // Raise the soft file-descriptor limit before exec-ing the real command.
227
- // macOS's default soft RLIMIT_NOFILE is 256 (launchd/system default), inherited
228
- // by the terminal that runs `lt dev up` and therefore by these detached children
229
- // — it is NOT a consequence of the lt-dev LaunchAgent (which runs only Caddy).
230
- // The dev file-watcher (nest/nuxt → chokidar) exhausts a soft-256 limit on a
231
- // monorepo → intermittent "EMFILE: too many open files, watch" crashes on boot
232
- // that force a manual `lt dev up`. We wrap the command in `sh -c "ulimit …; exec …"`:
233
- // - `exec` replaces the shell IN PLACE → the recorded PID and the detached
234
- // process group are still the real process (PID tracking + group-kill in
235
- // `terminateProcessGroup` keep working).
236
- // - `"$0" "$@"` pass cmd + args verbatim — no shell-quoting / injection.
237
- // - the cascade tries a high limit first, falling back on machines with a
238
- // lower `kern.maxfilesperproc`; `2>/dev/null` keeps it best-effort.
239
- // Note: because the outer `spawn('/bin/sh', …)` almost always succeeds, a bogus
240
- // `cmd` no longer surfaces as `pid === undefined` here — the inner `exec` fails
241
- // (exit 127) a few ms later. Callers briefly record a live-then-dead PID, which
242
- // `classifyComponentHealth` reaps as `dead`/`crashed` on the next status/up. We
243
- // deliberately don't watch for that here: a detached, unref'd child's 127 exit is
244
- // racy to observe, and callers must be self-correcting against real crashes anyway.
245
- const raiseFdLimit = 'ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true';
246
- child = (0, child_process_1.spawn)('/bin/sh', ['-c', `${raiseFdLimit}; exec "$0" "$@"`, cmd, ...args], {
263
+ const spawned = detachedSpawnCommand(cmd, args);
264
+ child = (0, child_process_1.spawn)(spawned.command, spawned.args, {
247
265
  cwd: opts.cwd,
248
266
  detached: true,
249
267
  env: opts.env,
250
268
  stdio: ['ignore', out, out],
251
269
  });
270
+ // spawn reports "could not start this at all" (missing executable, bad cwd)
271
+ // through an ASYNCHRONOUS 'error' event — the try/catch around spawn() never
272
+ // sees it. With no listener, Node escalates an unhandled 'error' to a process
273
+ // crash, so one unstartable child would take the whole CLI down with it. That
274
+ // is not hypothetical: on Windows `/bin/sh` does not exist, and this line is
275
+ // what turned that into "Test suite failed to run" instead of one red test.
276
+ // Nothing to do but swallow it — the child is detached and its log file is
277
+ // already closed below; callers are self-correcting against a dead PID.
278
+ child.on('error', () => undefined);
252
279
  child.unref();
253
280
  if (child.pid === undefined)
254
281
  return undefined;
@@ -330,7 +357,15 @@ function terminateProcessGroup(pid_1) {
330
357
  *
331
358
  * Resolves `true` on the first matching response, `false` on timeout. Never rejects.
332
359
  */
333
- function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status < 600) {
360
+ function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status < 600,
361
+ /**
362
+ * Optional early exit: return `true` once there is nothing left to wait FOR —
363
+ * typically "the process I am waiting on has exited". Without it a server that
364
+ * crashes on boot is indistinguishable from one that is still starting, and the
365
+ * caller burns the entire timeout (120s for the `lt dev test` API) before
366
+ * learning something that was decided in the first 300ms.
367
+ */
368
+ abort = () => false) {
334
369
  const start = Date.now();
335
370
  return new Promise((resolve) => {
336
371
  const tick = () => {
@@ -340,20 +375,21 @@ function waitForHttp(url, timeoutMs, ready = (status) => status >= 100 && status
340
375
  });
341
376
  let status = '';
342
377
  (_a = child.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (b) => (status += String(b)));
378
+ const retry = () => {
379
+ // Order matters: a LAST probe already ran above, so a server that came up
380
+ // just before dying is still reported ready. Only then does `abort` end it.
381
+ if (Date.now() - start > timeoutMs || abort())
382
+ return resolve(false);
383
+ setTimeout(tick, 500);
384
+ };
343
385
  child.on('close', () => {
344
386
  const code = Number(status.trim());
345
387
  // `000` (curl could not connect) parses to 0 → never "ready".
346
388
  if (Number.isFinite(code) && code > 0 && ready(code))
347
389
  return resolve(true);
348
- if (Date.now() - start > timeoutMs)
349
- return resolve(false);
350
- setTimeout(tick, 500);
351
- });
352
- child.on('error', () => {
353
- if (Date.now() - start > timeoutMs)
354
- return resolve(false);
355
- setTimeout(tick, 500);
390
+ retry();
356
391
  });
392
+ child.on('error', retry);
357
393
  };
358
394
  tick();
359
395
  });
@@ -204,10 +204,21 @@ function loadSession(root, sessionFile = SESSION_FILE) {
204
204
  }
205
205
  return null;
206
206
  }
207
- /** True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var). */
207
+ /**
208
+ * True if two paths resolve to the same location (normalising symlinks, e.g. /var → /private/var).
209
+ *
210
+ * `.native` rather than plain `realpathSync`, because on Windows the JS
211
+ * implementation follows symlinks but keeps 8.3 short components: a path spelled
212
+ * `C:\Users\RUNNER~1\…` and the same directory spelled `C:\Users\runneradmin\…`
213
+ * stay different strings. Both spellings occur in practice — `os.tmpdir()` hands
214
+ * out the short form while git prints the long one — so a registry entry written
215
+ * under one and a cwd under the other would read as a DIFFERENT checkout. The
216
+ * native variant asks the OS for the final name and collapses both.
217
+ * No behavioural difference on POSIX.
218
+ */
208
219
  function sameRealPath(a, b) {
209
220
  try {
210
- return (0, fs_1.realpathSync)(a) === (0, fs_1.realpathSync)(b);
221
+ return fs_1.realpathSync.native(a) === fs_1.realpathSync.native(b);
211
222
  }
212
223
  catch (_a) {
213
224
  return a === b;
@@ -16,31 +16,41 @@ exports.bringUpTestSession = bringUpTestSession;
16
16
  exports.buildShardPlaywrightInvocation = buildShardPlaywrightInvocation;
17
17
  exports.buildTestAppEnv = buildTestAppEnv;
18
18
  exports.hasTestSession = hasTestSession;
19
+ exports.isStackServing = isStackServing;
20
+ exports.migrationFailedError = migrationFailedError;
19
21
  exports.resolveTestSession = resolveTestSession;
20
22
  exports.runShardedTestSession = runShardedTestSession;
21
23
  exports.shardReportDir = shardReportDir;
24
+ exports.startTestApi = startTestApi;
22
25
  exports.tearDownAllTestSessions = tearDownAllTestSessions;
23
26
  exports.tearDownTestSession = tearDownTestSession;
24
27
  exports.testAppEntryCandidates = testAppEntryCandidates;
28
+ exports.unreachableStackError = unreachableStackError;
25
29
  /**
26
30
  * Ephemeral, isolated test session for `lt dev test`.
27
31
  *
28
32
  * Brings up a SECOND, fully separate stack (own URLs, own internal ports,
29
33
  * own Caddy block, own database) that runs PARALLEL to — and never touches —
30
34
  * the developer's `lt dev up` session. Used to run the Playwright E2E suite
31
- * against a clean, dedicated database so a developer can keep working in their
32
- * own environment while tests run, and so a test run never pollutes dev data.
35
+ * against a dedicated database so a developer can keep working in their own
36
+ * environment while tests run, and so a test run never pollutes dev data. That
37
+ * database outlives the run — the CLI never drops it — so the API is migrated
38
+ * before it starts, like every other environment (DEV-3289).
33
39
  *
34
40
  * Topology (for slug `svl`):
35
41
  * - dev session : svl.localhost / api.svl.localhost → db `<…>-local`
36
42
  * - test session : svl-test.localhost / api.svl-test.… → db `<…>-test`
37
43
  *
38
- * Both halves run BUILT for speed + prod-fidelity: the API COMPILED (`node dist`,
39
- * ts-node intermittently dies mid-run) and the App as the production Nitro output
40
- * (`nuxt build` → `node .output/server/index.mjs`, no Vite cold-compile). Each
41
- * falls back to its dev runner (`pnpm start` / `pnpm dev`) when no build output is
42
- * found. bringUp waits for a real 2xx on the API `/meta` before returning so the
43
- * suite never starts against a not-yet-serving API.
44
+ * Both halves run BUILT for speed + prod-fidelity: the API COMPILED (from `dist`,
45
+ * because ts-node intermittently dies mid-run) and the App as the production Nitro
46
+ * output (`nuxt build` → `node .output/server/index.mjs`, no Vite cold-compile).
47
+ * Each falls back to its dev runner (`pnpm start` / `pnpm dev`) when no build
48
+ * output is found. The API's runtime follows the project (`resolveApiRuntime`):
49
+ * a Bun-built bundle is started with `bun`, everything else with `node`.
50
+ *
51
+ * bringUp waits for BOTH halves to answer and THROWS when one never does, so the
52
+ * suite can never run against a dead stack (DEV-3208) — see `isStackServing` for
53
+ * what counts as answering.
44
54
  *
45
55
  * Lifecycle: `bringUpTestSession` → run Playwright → `tearDownTestSession`.
46
56
  * Teardown is idempotent and residue-free (processes, Caddy block, env bridge,
@@ -93,12 +103,13 @@ const TEST_PORT_BASE = 4500;
93
103
  * `systemSetup.initialAdmin.*` and seeds the admin ONCE on an empty DB (it never
94
104
  * overwrites an existing admin), so a set-up system exists before the suite runs.
95
105
  * A fresh template project's standard auth E2E specs assume such a system — they
96
- * do no self-setup — so without this they fail LOCALLY on the empty per-run DB.
106
+ * do no self-setup — so without this they fail LOCALLY on a test DB that has no
107
+ * users yet (its first run, or after a global-setup reset it).
97
108
  * These are the exact values the lt-monorepo template CI uses
98
109
  * (`.gitlab-ci.yml` / `.github/workflows/test.yml`), keeping CI ↔ local parity.
99
110
  *
100
- * Injected ONLY into the `lt dev test` API process (its DB is fresh + discarded
101
- * per run), NEVER into `lt dev up` — no surprise admin in the persistent dev DB.
111
+ * Injected ONLY into the `lt dev test` API process (its DB is the dedicated
112
+ * `<…>-test` one), NEVER into `lt dev up` — no surprise admin in the dev DB.
102
113
  */
103
114
  exports.TEST_INITIAL_ADMIN_ENV = {
104
115
  NSC__SYSTEM_SETUP__INITIAL_ADMIN__EMAIL: 'ci-admin@test.com',
@@ -270,7 +281,11 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
270
281
  log.info(` app: ${appUrl} → 127.0.0.1:${appPort}`);
271
282
  if (apiUrl)
272
283
  log.info(` api: ${apiUrl} → 127.0.0.1:${apiPort}`);
273
- log.info(` db: mongodb://127.0.0.1/${dbName} (reset before the suite by Playwright global-setup)`);
284
+ // No claim about resetting here: the CLI never drops this DB, and whether a
285
+ // project's global-setup does is the project's business. The claim used to be
286
+ // printed unconditionally and sent a diagnosis looking for a reset that never
287
+ // happened instead of at the un-migrated DB (DEV-3289).
288
+ log.info(` db: mongodb://127.0.0.1/${dbName}`);
274
289
  log.info('');
275
290
  const devEnv = (0, dev_env_1.buildDevEnv)({
276
291
  apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
@@ -288,40 +303,23 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
288
303
  // hands it to the Playwright child as well.
289
304
  const appEnv = buildTestAppEnv(devEnv.app.env);
290
305
  const pids = {};
291
- // --- API: compiled (`node dist`) for stability; fall back to the project's
292
- // own dev start script. `skipBuild` (sibling shards) reuses the dist the
293
- // first shard produced. Per-component PM detection mirrors `lt dev up`:
294
- // a monorepo with an npm api and a pnpm app must drive each correctly. ---
306
+ // --- API: compiled (`node dist`) for stability, migrated first; falls back to
307
+ // the project's own start script (see `startTestApi`). ---
295
308
  if (layout.apiDir && apiPort) {
296
- const apiPm = (0, dev_package_manager_1.pickPackageManager)(layout.apiDir);
297
- let build = 0;
298
- if (!skipBuild) {
299
- log.info(log.dim('Building API (compiled, for stable long runs) …'));
300
- build = yield (0, dev_process_1.runChildInherit)(apiPm.bin, apiPm.runScript('build'), { cwd: layout.apiDir, env: process.env });
301
- }
302
- const entry = (0, dev_api_launch_1.findCompiledEntry)(layout.apiDir);
303
- // Seed a throwaway initial admin into the fresh, isolated test DB so the
304
- // standard auth E2E specs run against a set-up system — locally exactly like
305
- // the lt-monorepo template CI. Defaults first so an explicitly inherited
309
+ // Seed a throwaway initial admin into the isolated test DB so the standard
310
+ // auth E2E specs run against a set-up system — locally exactly like the
311
+ // lt-monorepo template CI. Defaults first so an explicitly inherited
306
312
  // `NSC__…INITIAL_ADMIN__…` still wins (deliberate override respected). Only
307
313
  // reached from `lt dev test` — `lt dev up` never calls bringUpTestSession.
308
314
  const apiEnv = Object.assign(Object.assign(Object.assign({}, exports.TEST_INITIAL_ADMIN_ENV), devEnv.api.env), { NODE_ENV: 'local' });
309
- let apiSpawn;
310
- if (build === 0 && entry) {
311
- apiSpawn = (0, dev_process_1.spawnDetached)('node', [entry], {
312
- cwd: layout.apiDir,
313
- env: apiEnv,
314
- logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
315
- });
316
- }
317
- else {
318
- log.warn(`compiled API not available — falling back to \`${apiPm.bin} start\` (ts-node).`);
319
- apiSpawn = (0, dev_process_1.spawnDetached)(apiPm.bin, apiPm.runScript('start'), {
320
- cwd: layout.apiDir,
321
- env: apiEnv,
322
- logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
323
- });
324
- }
315
+ const apiSpawn = yield startTestApi({
316
+ apiDir: layout.apiDir,
317
+ apiEnv,
318
+ dbName,
319
+ log,
320
+ logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
321
+ skipBuild,
322
+ });
325
323
  if (apiSpawn)
326
324
  pids.api = apiSpawn.pid;
327
325
  }
@@ -387,26 +385,43 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
387
385
  (0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() }, names.sessionFile);
388
386
  // ENV bridge for external tooling (kept separate from the dev `.env`).
389
387
  (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName, names.bridgeFile);
390
- // Wait for the test App to answer (best-effort).
388
+ // THIS stack's log paths. Resolved BEFORE the readiness waits below, because
389
+ // the abort messages point at them: the log is the only place a crash-on-boot
390
+ // is recorded, and an abort that withholds it just relocates the guesswork.
391
+ // `apiLogPath` is also handed to the caller so the auth E2E specs (via
392
+ // NEST_SERVER_LOG) read the exact isolated log — correct per shard.
393
+ const apiLogPath = layout.apiDir ? (0, path_1.join)(layout.root, '.lt-dev', names.apiLog) : undefined;
394
+ const appLogPath = layout.appDir ? (0, path_1.join)(layout.root, '.lt-dev', names.appLog) : undefined;
395
+ // Both waits below ABORT rather than warn (DEV-3208). A warning let Playwright
396
+ // run the whole suite against a dead stack: every data-dependent spec passed
397
+ // trivially or skipped, so a run that verified NOTHING was indistinguishable
398
+ // from one that verified everything — worse than a red run, because nobody
399
+ // goes looking. `isStackServing` decides what counts as up, and the liveness
400
+ // guard turns a crash-on-boot into an abort in the same 300ms it took to
401
+ // happen, instead of after the full timeout.
402
+ //
403
+ // `gone` is deliberately true for an ABSENT pid too: reaching here means the
404
+ // spawn was attempted, so no pid means it failed outright and there is nothing
405
+ // left to wait for. Being wrong here costs nothing — the timeout still bounds
406
+ // the wait, so this only ever makes the abort arrive sooner.
407
+ const gone = (pid) => pid === undefined || !(0, dev_state_1.isPidAlive)(pid);
408
+ // Wait for the test App to answer.
391
409
  if (appUrl) {
392
410
  log.info(log.dim(`Waiting for ${appUrl} …`));
393
- yield (0, dev_process_1.waitForHttp)(appUrl, 90000);
411
+ const appReady = yield (0, dev_process_1.waitForHttp)(appUrl, 90000, isStackServing, () => gone(pids.app));
412
+ if (!appReady)
413
+ throw unreachableStackError('App', appUrl, appLogPath);
394
414
  }
395
- // Wait for the test API to actually SERVE (real 2xx on /meta) before handing
396
- // off to Playwright. Previously bringUp only waited for the App, so a compiled
397
- // API still connecting to Mongo made the first specs skip via the suite's
398
- // `ensureApiReachableOrSkip` guard (the API-readiness race). A strict 2xx is
399
- // required: Caddy answers 502 while its upstream is still booting, which the
400
- // default (lenient) predicate would accept as "up".
415
+ // Wait for the test API to actually SERVE before handing off to Playwright.
416
+ // Previously bringUp only waited for the App, so a compiled API still
417
+ // connecting to its database made the first specs skip via the suite's
418
+ // `ensureApiReachableOrSkip` guard (the API-readiness race).
401
419
  if (apiUrl) {
402
420
  log.info(log.dim(`Waiting for ${apiUrl}/meta …`));
403
- const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, (status) => status >= 200 && status < 300);
421
+ const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, isStackServing, () => gone(pids.api));
404
422
  if (!apiReady)
405
- log.warn(`Test API did not answer 2xx on ${apiUrl}/meta within 120s — the first specs may skip.`);
423
+ throw unreachableStackError('API', `${apiUrl}/meta`, apiLogPath);
406
424
  }
407
- // Expose THIS stack's API log path so the caller can point the auth E2E specs
408
- // (via NEST_SERVER_LOG) at the exact isolated log — correct per shard.
409
- const apiLogPath = layout.apiDir ? (0, path_1.join)(layout.root, '.lt-dev', names.apiLog) : undefined;
410
425
  return { apiLogPath, apiUrl, appEnv, appUrl, dbName, pids, testIdentity };
411
426
  });
412
427
  }
@@ -467,6 +482,41 @@ function buildTestAppEnv(appEnv) {
467
482
  function hasTestSession(root) {
468
483
  return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
469
484
  }
485
+ /**
486
+ * Whether an HTTP status proves the component ITSELF answered.
487
+ *
488
+ * Readiness here means liveness, not health: the question is "did my process get
489
+ * far enough to serve HTTP", not "did it like this particular request". So a 404
490
+ * and a 500 both count as ready — they came FROM the upstream — while Caddy's
491
+ * gateway errors do not, because Caddy returns those for the entire window in
492
+ * which its upstream is not yet listening.
493
+ *
494
+ * The 404 case is the load-bearing one. The API probe targets `/meta`, an
495
+ * endpoint `@lenne.tech/nest-server` provides and `nest-base` does not: a healthy
496
+ * nest-base API answers 404 there (verified against a live one). The previous
497
+ * strict-2xx predicate was harmless while a failed wait only logged a warning,
498
+ * but this module now ABORTS on it — under strict 2xx that would block
499
+ * `lt dev test` for every nest-base project on a perfectly healthy API, which is
500
+ * worse than the false-green it replaces. Probing a list of per-framework paths
501
+ * would work too, but it needs updating for every new template; "something other
502
+ * than the proxy answered" needs no such list.
503
+ */
504
+ function isStackServing(status) {
505
+ return status > 0 && ![502, 503, 504].includes(status);
506
+ }
507
+ /**
508
+ * The abort thrown when the test DB cannot be migrated.
509
+ *
510
+ * An abort rather than a warning for the same reason as `unreachableStackError`:
511
+ * a suite run on an un-migrated DB tests a data state no environment has, and
512
+ * whatever it reports is about the test setup, not about the feature. Unlike the
513
+ * API's boot, `migrate:up` runs in the foreground, so its output is already on
514
+ * screen right above this message.
515
+ */
516
+ function migrationFailedError(dbName, exitCode) {
517
+ return new Error(`migrate:up failed (exit ${String(exitCode)}) against the test DB "${dbName}" — test API not started, ` +
518
+ 'because a suite on an un-migrated DB tests a state no environment has. The migration output is above.');
519
+ }
470
520
  /** Build the dedicated test identity + test DB name for a project. */
471
521
  function resolveTestSession(layout, baseIdentity, shardIndex, devDbName) {
472
522
  const names = testStackNames(shardIndex);
@@ -551,6 +601,50 @@ function runShardedTestSession(layout, baseIdentity, log, opts) {
551
601
  function shardReportDir(root, shardIndex) {
552
602
  return (0, path_1.join)(root, '.lt-dev', `shard.${shardIndex}.playwright-report`);
553
603
  }
604
+ /**
605
+ * Start the test stack's API: build → apply pending migrations → start the
606
+ * compiled bundle, or fall back to the project's own `start` script when no
607
+ * bundle is available.
608
+ *
609
+ * The compiled start bypasses that `start` script and therefore the
610
+ * `migrate:up` it chains in front of the server. The test DB outlives every
611
+ * run — the CLI never drops it — so without the explicit step here it drifted
612
+ * behind every environment that does migrate (DEV-3289). The migration runs
613
+ * with `apiEnv`, the API's own env, so both resolve the same database, and
614
+ * before the spawn, because the API syncs its indexes against whatever it finds
615
+ * on boot. A failed migration throws `migrationFailedError`.
616
+ *
617
+ * `skipBuild` (sibling shards) reuses the dist the first shard produced but
618
+ * still migrates: every shard has a database of its own. The fallback does not
619
+ * migrate itself — the `start` script it runs owns that, exactly as under
620
+ * `lt dev up`. Per-directory package manager, like `lt dev up`: a monorepo with
621
+ * an npm API and a pnpm App must drive each with its own.
622
+ */
623
+ function startTestApi(opts) {
624
+ return __awaiter(this, void 0, void 0, function* () {
625
+ const { apiDir, apiEnv, dbName, log, logFile, skipBuild } = opts;
626
+ const apiPm = (0, dev_package_manager_1.pickPackageManager)(apiDir);
627
+ let build = 0;
628
+ if (!skipBuild) {
629
+ log.info(log.dim('Building API (compiled, for stable long runs) …'));
630
+ build = yield (0, dev_process_1.runChildInherit)(apiPm.bin, apiPm.runScript('build'), { cwd: apiDir, env: process.env });
631
+ }
632
+ const entry = (0, dev_api_launch_1.findCompiledEntry)(apiDir);
633
+ if (build === 0 && entry) {
634
+ const migration = yield (0, dev_api_launch_1.applyPendingMigrations)({ apiDir, env: apiEnv, pm: apiPm });
635
+ if (migration.status === 'failed')
636
+ throw migrationFailedError(dbName, migration.exitCode);
637
+ if (migration.status === 'skipped')
638
+ log.info(log.dim(`no migrate:up script — ${dbName} is not migrated`));
639
+ // Runtime follows the PROJECT, never a hardcoded default: a `nest-base` API
640
+ // bundles with `Bun.build({ target: 'bun' })` and dies under node with
641
+ // "__require is not a function" (DEV-3208).
642
+ return (0, dev_process_1.spawnDetached)((0, dev_api_launch_1.resolveApiRuntime)(apiDir), [entry], { cwd: apiDir, env: apiEnv, logFile });
643
+ }
644
+ log.warn(`compiled API not available — falling back to \`${apiPm.bin} start\` (ts-node).`);
645
+ return (0, dev_process_1.spawnDetached)(apiPm.bin, apiPm.runScript('start'), { cwd: apiDir, env: apiEnv, logFile });
646
+ });
647
+ }
554
648
  /**
555
649
  * Tear down the unsharded test stack AND every sharded stack discovered on disk
556
650
  * (`state.test.<i>.json` in `.lt-dev/`). Used by `lt dev test down` so a
@@ -661,6 +755,23 @@ function tearDownTestSession(layout_1, baseIdentity_1, log_1) {
661
755
  function testAppEntryCandidates() {
662
756
  return [`${exports.TEST_NITRO_OUTPUT_DIR}/server/index.mjs`, '.output/server/index.mjs'];
663
757
  }
758
+ /**
759
+ * The abort thrown when a test-stack component never becomes reachable.
760
+ *
761
+ * Phrased as an abort, not a diagnosis, because the cause lives in the component's
762
+ * own log and nowhere else — the process is detached, so its stderr never reaches
763
+ * this terminal. Naming the log file is therefore the whole value of the message:
764
+ * an abort that only says "did not come up" moves the guesswork one step later
765
+ * instead of ending it.
766
+ *
767
+ * `lt dev test` catches this, reports it, tears the stack down and exits non-zero,
768
+ * so Playwright never starts (DEV-3208).
769
+ */
770
+ function unreachableStackError(component, url, logFile) {
771
+ const where = logFile ? ` Why it never came up: ${logFile}` : '';
772
+ return new Error(`Test ${component} never became reachable at ${url} — aborting before Playwright runs, ` +
773
+ `because a suite run against a dead ${component} passes trivially and proves nothing.${where}`);
774
+ }
664
775
  /**
665
776
  * Resolve the per-stack file/identity names. For a sharded run (`shardIndex`
666
777
  * given) everything gets a `.<i>` / `-<i>` suffix so N stacks coexist without
@@ -20,6 +20,17 @@ exports.failRun = failRun;
20
20
  * buffered output, including the spinner's own failure message, which is the one
21
21
  * line the operator actually needs.
22
22
  *
23
+ * That choice carries a precondition worth stating, because it was violated:
24
+ * setting `process.exitCode` only ends the process once **the event loop drains**,
25
+ * and `bin/lt` has no terminal `process.exit()` after `run()`. A running ora
26
+ * spinner holds the loop open on two counts — its `setInterval` for the animation
27
+ * and the `stdin.resume()` its stdin discarder performs. So a command that called
28
+ * `failRun` and returned while a spinner was still spinning did not exit at all:
29
+ * no message, no prompt back, and the terminal left in raw mode. The remedy is to
30
+ * stop the spinner, NOT to switch this helper to `process.exit()` — that would
31
+ * trade a hang for the truncated-output bug this helper exists to avoid.
32
+ * `__tests__/spinner-lifetime.test.ts` enforces the spinner half.
33
+ *
23
34
  * **Guarded by `fromGluegunMenu`**, like the CLI's other exit-code call sites
24
35
  * (`dev test`, `dev tunnel`, `tools ocr`, `workspace-integration`): inside the
25
36
  * interactive `lt` menu a command is one step of a longer session, and failing
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nonInteractiveGitEnv = nonInteractiveGitEnv;
4
+ /**
5
+ * The environment git subprocesses run with so they never block on a prompt.
6
+ *
7
+ * Two variables, one rule each:
8
+ *
9
+ * `GIT_TERMINAL_PROMPT=0` stops git asking for credentials on a dead terminal.
10
+ * A prompt in a detached or CI child is not a question anyone can answer — it is
11
+ * a hang that looks like a slow network.
12
+ *
13
+ * `GIT_SSH_COMMAND` only supplies a DEFAULT, and the distinction is expensive.
14
+ * `BatchMode=yes` makes ssh fail rather than PROMPT — but an agent that stalls is
15
+ * not a prompt. On a 1Password-backed machine the agent is reachable and every
16
+ * signature needs interactive approval; unattended it waits, then reports
17
+ * `communication with agent failed`. Measured at 61s per fetch, which made
18
+ * `lt git update --dry-run` take 62s and `lt git create --dry-run` 123s (two
19
+ * fetches). `ConnectTimeout` does not bound that — it covers the TCP connect,
20
+ * not the agent.
21
+ *
22
+ * Waiting for a human to approve a key is legitimate for an interactive command,
23
+ * so the default itself is unchanged. What was wrong was assigning it
24
+ * UNCONDITIONALLY: that overrode anyone who had configured ssh deliberately,
25
+ * including a test harness trying to make the behaviour deterministic. A
26
+ * caller's own value therefore always wins (`IdentityAgent=none` in their env
27
+ * drops the same fetch to ~1s with a clean `Permission denied (publickey)`); the
28
+ * fallback only keeps an unconfigured machine from hanging.
29
+ *
30
+ * This exists as an environment object rather than a shell prefix because
31
+ * `VAR=value git …` is not assignment syntax to cmd.exe — it reads the whole
32
+ * thing as a command name and fails with "'GIT_TERMINAL_PROMPT' is not
33
+ * recognized". Handing the child an env works on every platform, and it is the
34
+ * only spelling that does. Keep it here rather than inline at each call site:
35
+ * this is the single place the default is defined, which is what lets a test
36
+ * assert that nobody assigns GIT_SSH_COMMAND unconditionally anywhere else.
37
+ *
38
+ * One caveat if you ever read a variable back off the returned object: spreading
39
+ * `process.env` produces a PLAIN object, which on Windows loses the
40
+ * case-insensitive lookup the real `process.env` proxy provides. The value is
41
+ * intact under its original spelling (`Path`, not `PATH`), and handing the whole
42
+ * object to `spawn` works — but `result.PATH` is undefined there.
43
+ */
44
+ function nonInteractiveGitEnv(base = process.env) {
45
+ return Object.assign(Object.assign({}, base), { GIT_SSH_COMMAND: base.GIT_SSH_COMMAND || 'ssh -o ConnectTimeout=5 -o BatchMode=yes', GIT_TERMINAL_PROMPT: '0' });
46
+ }
@@ -38,8 +38,11 @@ const LINT_STAGED_FILES = [
38
38
  */
39
39
  function findDangerousFixFlagUsage(appDir, workspaceRoot) {
40
40
  const base = workspaceRoot !== null && workspaceRoot !== void 0 ? workspaceRoot : appDir;
41
- const files = [...(workspaceRoot ? [(0, path_1.join)(workspaceRoot, 'scripts', 'check.mjs')] : []), ...fixFlagFiles(appDir, workspaceRoot)];
42
- return [...new Set(files)].filter((file) => usesDangerousFixFlag(file)).map((file) => (0, path_1.relative)(base, file) || file);
41
+ const files = [
42
+ ...(workspaceRoot ? [(0, path_1.join)(workspaceRoot, 'scripts', 'check.mjs')] : []),
43
+ ...fixFlagFiles(appDir, workspaceRoot),
44
+ ];
45
+ return [...new Set(files)].filter((file) => usesDangerousFixFlag(file)).map((file) => repoRelative(base, file));
43
46
  }
44
47
  /**
45
48
  * Remove `--fix-suggestions` and `--fix-dangerously` from the project's lint commands
@@ -57,7 +60,7 @@ function healDangerousOxlintFixFlags(appDir, workspaceRoot) {
57
60
  if (!usesDangerousFixFlag(file)) {
58
61
  continue;
59
62
  }
60
- const rel = (0, path_1.relative)(base, file) || file;
63
+ const rel = repoRelative(base, file);
61
64
  const dir = (0, path_1.join)(file, '..');
62
65
  const name = (0, path_1.relative)(dir, file);
63
66
  if (isSymlink(file)) {
@@ -203,6 +206,20 @@ function isTracked(dir, relPath) {
203
206
  return false;
204
207
  }
205
208
  }
209
+ /**
210
+ * `file` relative to `base`, always with forward slashes.
211
+ *
212
+ * These strings are reported to the user next to git facts ("not tracked by
213
+ * git", "uncommitted changes") and name files inside the repository, which git
214
+ * itself always spells with `/`. `relative()` returns the OS-native form, so on
215
+ * Windows the report would say `projects\app\package.json` while `git status`
216
+ * says `projects/app/package.json` for the same file. Falls back to the
217
+ * absolute path when there is no relative form (`base` IS the file).
218
+ */
219
+ function repoRelative(base, file) {
220
+ const rel = (0, path_1.relative)(base, file);
221
+ return rel ? rel.split(path_1.sep).join('/') : file;
222
+ }
206
223
  function usesDangerousFixFlag(file) {
207
224
  try {
208
225
  // Follows a symlink on purpose: a linked config still feeds the lint run, so it
@@ -90,9 +90,10 @@ const ts = __importStar(require("typescript"));
90
90
  * Detection runs on the TypeScript AST, not on regex-stripped text. A regex
91
91
  * "lexer" has no string/template/regex-literal state, so a `/*` or `//` inside a
92
92
  * literal earlier in the file silently erased the guard and triggered the very
93
- * overwrite this function must avoid. `lib/strip-comments.ts` solves the comment
94
- * half properly (TS scanner) and would have been the right reuse; the AST solves
95
- * comments AND nesting in one step, and recognises a backtick require for free.
93
+ * overwrite this function must avoid. Scanning is not enough either: a standalone
94
+ * `ts.createScanner` loop desynchronises on the first interpolating template
95
+ * literal (that is what retired `lib/strip-comments.ts`). The AST solves comments
96
+ * AND nesting in one step, and recognises a backtick require for free.
96
97
  *
97
98
  * ## Recoverability
98
99
  *