@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
@@ -43,6 +43,7 @@ const child_process_1 = require("child_process");
43
43
  const fs_1 = require("fs");
44
44
  const path_1 = require("path");
45
45
  const dev_project_1 = require("./dev-project");
46
+ const dev_state_1 = require("./dev-state");
46
47
  const dev_ticket_1 = require("./dev-ticket");
47
48
  /** Branch prefix `lt ticket start` creates worktrees on. */
48
49
  const TICKET_BRANCH_PREFIX = 'feat/';
@@ -188,9 +189,15 @@ function planOrphanTicketDbSweep(args) {
188
189
  * never auto-dropped).
189
190
  */
190
191
  function planRegistryPrune(registry) {
191
- return Object.entries(registry.projects)
192
- .filter(([, entry]) => entry.path && !(0, fs_1.existsSync)(entry.path))
193
- .map(([key]) => key);
192
+ return (Object.entries(registry.projects)
193
+ // A relative stored path is not evidence of anything: `existsSync` resolves
194
+ // it against the current process's cwd, so a perfectly live project reads as
195
+ // gone from any other directory — and pruning it reclaims its slug and its
196
+ // reserved internal ports for the next project that asks. "Cannot verify" is
197
+ // not "absent"; these are left alone until a registry write from inside the
198
+ // project replaces the path with an absolute one.
199
+ .filter(([, entry]) => (0, dev_state_1.isVerifiablePath)(entry.path) && !(0, fs_1.existsSync)(entry.path))
200
+ .map(([key]) => key));
194
201
  }
195
202
  /**
196
203
  * Orphaned smoke-test databases. The `/lt-dev:fullstack:smoke-test` command
@@ -218,12 +225,15 @@ function planSmokeTestDbSweep(args) {
218
225
  const entries = Object.entries(registry.projects);
219
226
  const smokeRunLive = entries.some(([key, entry]) => (key === exports.SMOKE_TEST_DB_PREFIX || key.startsWith(`${exports.SMOKE_TEST_DB_PREFIX}-`)) &&
220
227
  entry.path &&
221
- (0, fs_1.existsSync)(entry.path));
228
+ (!(0, dev_state_1.isVerifiablePath)(entry.path) || (0, fs_1.existsSync)(entry.path)));
222
229
  if (smokeRunLive)
223
230
  return [];
224
231
  const protectedNames = new Set();
225
232
  for (const [, entry] of entries) {
226
- if (entry.dbName && entry.path && (0, fs_1.existsSync)(entry.path)) {
233
+ // `!isVerifiablePath` → we cannot prove the checkout is gone, so the database
234
+ // keeps its protection. Erring the other way would expose a live project's DB
235
+ // to the orphan sweep on the strength of a path we know we cannot read.
236
+ if (entry.dbName && entry.path && (!(0, dev_state_1.isVerifiablePath)(entry.path) || (0, fs_1.existsSync)(entry.path))) {
227
237
  protectedNames.add(entry.dbName);
228
238
  protectedNames.add((0, dev_project_1.deriveTestDbName)(entry.dbName));
229
239
  }
@@ -51,6 +51,8 @@ const fs_1 = require("fs");
51
51
  const os_1 = require("os");
52
52
  const path_1 = require("path");
53
53
  const caddy_1 = require("./caddy");
54
+ const dev_process_1 = require("./dev-process");
55
+ const platform_1 = require("./platform");
54
56
  /**
55
57
  * Resolve the user's home directory in a test-overridable way.
56
58
  *
@@ -139,12 +141,12 @@ function installService() {
139
141
  ok: false,
140
142
  };
141
143
  }
142
- const caddyBin = opts.caddyBin || (yield resolveCaddyBin());
144
+ const caddyBin = opts.caddyBin || (yield resolveCaddyBin(opts.lookup));
143
145
  if (!caddyBin) {
144
146
  return {
145
147
  bootstrapped: false,
146
148
  created: false,
147
- message: 'caddy not found on PATH. Install with `brew install caddy` (macOS) or your package manager (Linux).',
149
+ message: caddyMissingMessage(),
148
150
  ok: false,
149
151
  };
150
152
  }
@@ -249,14 +251,19 @@ StandardError=append:${cfg.errFile}
249
251
  WantedBy=default.target
250
252
  `;
251
253
  }
252
- /** Resolve the absolute path of `caddy` via `which` so launchd has a guaranteed path. */
254
+ /**
255
+ * Absolute path of `caddy`, so launchd/systemd get a guaranteed path rather than
256
+ * a PATH lookup in an environment they do not share with the user's shell.
257
+ *
258
+ * Resolved in-process instead of by spawning `which`: there is no `which` on
259
+ * Windows, and `where.exe` prints every match rather than the first. The async
260
+ * signature is kept because callers await it and a future platform may need to
261
+ * ask something that does spawn.
262
+ */
253
263
  function resolveCaddyBin() {
254
- return __awaiter(this, void 0, void 0, function* () {
255
- const r = yield activeRunner('which', ['caddy']);
256
- if (!r.ok)
257
- return undefined;
258
- const line = r.stdout.split('\n').find((s) => s.trim().length > 0);
259
- return line ? line.trim() : undefined;
264
+ return __awaiter(this, arguments, void 0, function* (options = {}) {
265
+ var _a;
266
+ return (_a = (0, platform_1.findExecutable)('caddy', options)) !== null && _a !== void 0 ? _a : undefined;
260
267
  });
261
268
  }
262
269
  /** Inject a custom runner (tests). Pass `null` to reset to the real spawner. */
@@ -315,6 +322,16 @@ function waitForServiceReady() {
315
322
  return false;
316
323
  });
317
324
  }
325
+ /** Install hint for the current platform — `brew` is not advice on Linux or Windows. */
326
+ function caddyMissingMessage() {
327
+ if (process.platform === 'darwin') {
328
+ return 'caddy not found on PATH. Install with `brew install caddy`.';
329
+ }
330
+ if (process.platform === 'win32') {
331
+ return 'caddy not found on PATH. Install with `winget install CaddyServer.Caddy` or `scoop install caddy`.';
332
+ }
333
+ return 'caddy not found on PATH. Install it with your package manager.';
334
+ }
318
335
  function defaultShellRunner(cmd, args) {
319
336
  return new Promise((resolve) => {
320
337
  var _a, _b;
@@ -396,13 +413,10 @@ function installLinux(paths, changed, existed) {
396
413
  };
397
414
  });
398
415
  }
416
+ /** See `caddy.ts#caddyDaemonRunning` for why this is not a `curl` any more. */
399
417
  function pingCaddyAdmin() {
400
- return new Promise((resolve) => {
401
- const child = (0, child_process_1.spawn)('curl', ['-fsS', '-o', '/dev/null', '--max-time', '1', 'http://127.0.0.1:2019/config/'], {
402
- stdio: ['ignore', 'ignore', 'ignore'],
403
- });
404
- child.on('error', () => resolve(false));
405
- child.on('close', (code) => resolve(code === 0));
418
+ return __awaiter(this, void 0, void 0, function* () {
419
+ return (yield (0, dev_process_1.httpStatus)('http://127.0.0.1:2019/config/', 1000)) !== null;
406
420
  });
407
421
  }
408
422
  function sleep(ms) {
@@ -18,6 +18,7 @@ exports.clearSession = clearSession;
18
18
  exports.detectSlugConflict = detectSlugConflict;
19
19
  exports.isPidAlive = isPidAlive;
20
20
  exports.isValidPid = isValidPid;
21
+ exports.isVerifiablePath = isVerifiablePath;
21
22
  exports.loadRegistry = loadRegistry;
22
23
  exports.loadSession = loadSession;
23
24
  exports.sameRealPath = sameRealPath;
@@ -142,13 +143,33 @@ function clearSession(root, sessionFile = SESSION_FILE) {
142
143
  */
143
144
  function detectSlugConflict(slug, root) {
144
145
  const entry = loadRegistry().projects[slug];
145
- if (!(entry === null || entry === void 0 ? void 0 : entry.path) || sameRealPath(entry.path, root))
146
+ // A relative stored path cannot be compared to anything: its base is lost, so
147
+ // it names a different directory from every cwd. Entries like that were written
148
+ // by `lt fullstack init` before the path was absolutised, and comparing one
149
+ // produced "slug 't-5' is also registered to another checkout: t-5". Claiming a
150
+ // conflict we cannot establish is worse than staying quiet — the next registry
151
+ // write from inside the project repairs the entry anyway.
152
+ if (!(entry === null || entry === void 0 ? void 0 : entry.path) || !isVerifiablePath(entry.path) || sameRealPath(entry.path, root))
146
153
  return null;
147
154
  const session = loadSession(entry.path);
148
155
  const otherSessionAlive = !!session && [session.pids.api, session.pids.app].some((p) => typeof p === 'number' && isPidAlive(p));
149
156
  return { otherPath: entry.path, otherSessionAlive };
150
157
  }
151
- /** Check whether a process with the given PID is currently alive. */
158
+ /**
159
+ * Check whether a process with the given PID is currently alive.
160
+ *
161
+ * Signal 0 delivers nothing; it only asks the kernel whether the target exists
162
+ * and may be signalled. The two failures mean opposite things, and collapsing
163
+ * them was a bug:
164
+ *
165
+ * - `ESRCH` — no such process. Dead.
166
+ * - `EPERM` — **the process EXISTS**, this user may not signal it.
167
+ *
168
+ * Reading `EPERM` as dead made `classifyComponentHealth` report a running
169
+ * component as `dead`, so `lt dev up` killed and restarted a healthy stack.
170
+ * Reproducible on any POSIX machine: `process.kill(1, 0)` throws `EPERM`.
171
+ * More likely on Windows, where an elevated shell is ordinary.
172
+ */
152
173
  function isPidAlive(pid) {
153
174
  if (!isValidPid(pid))
154
175
  return false;
@@ -156,14 +177,29 @@ function isPidAlive(pid) {
156
177
  process.kill(pid, 0);
157
178
  return true;
158
179
  }
159
- catch (_a) {
160
- return false;
180
+ catch (err) {
181
+ return (err === null || err === void 0 ? void 0 : err.code) === 'EPERM';
161
182
  }
162
183
  }
163
184
  /** Validate a PID — positive integer, within plausible range. */
164
185
  function isValidPid(pid) {
165
186
  return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && pid < 4194304;
166
187
  }
188
+ /**
189
+ * True when a stored registry path can be checked against the file system.
190
+ *
191
+ * A relative path in the registry has lost the directory it was relative TO, so
192
+ * `existsSync` / `realpathSync` answer about whatever directory the current
193
+ * process started in. Every such answer is meaningless, and two of them were
194
+ * actively harmful: `planRegistryPrune` reclaimed a live project's slug and
195
+ * reserved ports, and `detectSlugConflict` reported a project as colliding with
196
+ * itself. Legacy entries are therefore treated as "cannot determine" rather than
197
+ * as "gone"; running any `lt dev` command inside the project rewrites the entry
198
+ * with an absolute path and clears the condition.
199
+ */
200
+ function isVerifiablePath(storedPath) {
201
+ return !!storedPath && (0, path_1.isAbsolute)(storedPath);
202
+ }
167
203
  /** Load the central registry; returns an empty one if missing or unreadable. */
168
204
  function loadRegistry() {
169
205
  if (!(0, fs_1.existsSync)(REGISTRY_PATH))
@@ -1,37 +1,14 @@
1
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.TEST_NITRO_OUTPUT_DIR = exports.TEST_NUXT_BUILD_DIR = exports.TEST_INITIAL_ADMIN_ENV = void 0;
13
- exports.ensurePlaywrightBrowsers = ensurePlaywrightBrowsers;
14
- exports.autoShardCount = autoShardCount;
15
- exports.bringUpTestSession = bringUpTestSession;
16
- exports.buildShardPlaywrightInvocation = buildShardPlaywrightInvocation;
17
- exports.buildTestAppEnv = buildTestAppEnv;
18
- exports.hasTestSession = hasTestSession;
19
- exports.isStackServing = isStackServing;
20
- exports.resolveTestSession = resolveTestSession;
21
- exports.runShardedTestSession = runShardedTestSession;
22
- exports.shardReportDir = shardReportDir;
23
- exports.tearDownAllTestSessions = tearDownAllTestSessions;
24
- exports.tearDownTestSession = tearDownTestSession;
25
- exports.testAppEntryCandidates = testAppEntryCandidates;
26
- exports.unreachableStackError = unreachableStackError;
27
2
  /**
28
3
  * Ephemeral, isolated test session for `lt dev test`.
29
4
  *
30
5
  * Brings up a SECOND, fully separate stack (own URLs, own internal ports,
31
6
  * own Caddy block, own database) that runs PARALLEL to — and never touches —
32
7
  * the developer's `lt dev up` session. Used to run the Playwright E2E suite
33
- * against a clean, dedicated database so a developer can keep working in their
34
- * own environment while tests run, and so a test run never pollutes dev data.
8
+ * against a dedicated database so a developer can keep working in their own
9
+ * environment while tests run, and so a test run never pollutes dev data. That
10
+ * database outlives the run — the CLI never drops it — so the API is migrated
11
+ * before it starts, like every other environment (DEV-3289).
35
12
  *
36
13
  * Topology (for slug `svl`):
37
14
  * - dev session : svl.localhost / api.svl.localhost → db `<…>-local`
@@ -52,7 +29,33 @@ exports.unreachableStackError = unreachableStackError;
52
29
  * Teardown is idempotent and residue-free (processes, Caddy block, env bridge,
53
30
  * session file, registry entry), so a stale session is always safely reclaimed.
54
31
  */
55
- const child_process_1 = require("child_process");
32
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
33
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
34
+ return new (P || (P = Promise))(function (resolve, reject) {
35
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
36
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
37
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
38
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
39
+ });
40
+ };
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.TEST_NITRO_OUTPUT_DIR = exports.TEST_NUXT_BUILD_DIR = exports.TEST_INITIAL_ADMIN_ENV = void 0;
43
+ exports.ensurePlaywrightBrowsers = ensurePlaywrightBrowsers;
44
+ exports.autoShardCount = autoShardCount;
45
+ exports.bringUpTestSession = bringUpTestSession;
46
+ exports.buildShardPlaywrightInvocation = buildShardPlaywrightInvocation;
47
+ exports.buildTestAppEnv = buildTestAppEnv;
48
+ exports.hasTestSession = hasTestSession;
49
+ exports.isStackServing = isStackServing;
50
+ exports.migrationFailedError = migrationFailedError;
51
+ exports.resolveTestSession = resolveTestSession;
52
+ exports.runShardedTestSession = runShardedTestSession;
53
+ exports.shardReportDir = shardReportDir;
54
+ exports.startTestApi = startTestApi;
55
+ exports.tearDownAllTestSessions = tearDownAllTestSessions;
56
+ exports.tearDownTestSession = tearDownTestSession;
57
+ exports.testAppEntryCandidates = testAppEntryCandidates;
58
+ exports.unreachableStackError = unreachableStackError;
56
59
  const fs_1 = require("fs");
57
60
  const os_1 = require("os");
58
61
  const path_1 = require("path");
@@ -65,6 +68,7 @@ const dev_package_manager_1 = require("./dev-package-manager");
65
68
  const dev_patches_1 = require("./dev-patches");
66
69
  const dev_process_1 = require("./dev-process");
67
70
  const dev_project_1 = require("./dev-project");
71
+ const platform_1 = require("./platform");
68
72
  const dev_state_1 = require("./dev-state");
69
73
  /**
70
74
  * Make sure the Playwright browser build the app's OWN playwright version needs
@@ -79,12 +83,22 @@ const dev_state_1 = require("./dev-state");
79
83
  * fails (offline), the suite still runs and Playwright's own error explains it.
80
84
  */
81
85
  function ensurePlaywrightBrowsers(appDir, pm, logInfo) {
86
+ const note = () => {
87
+ logInfo === null || logInfo === void 0 ? void 0 : logInfo('playwright install chromium failed — continuing (the suite reports missing browsers itself).');
88
+ };
82
89
  try {
83
90
  const args = pm.exec('playwright', ['install', 'chromium']);
84
- (0, child_process_1.execFileSync)(pm.bin, args, { cwd: appDir, stdio: 'ignore', timeout: 300000 });
91
+ // `spawnCmdSync` (spawnSync) does NOT throw on a non-zero exit, unlike the
92
+ // `execFileSync` this replaced — so the status is checked explicitly.
93
+ // Without it the failure would be silent and the suite would die later on a
94
+ // missing browser, which reads as a broken spec rather than a missing install.
95
+ const result = (0, platform_1.spawnCmdSync)(pm.bin, args, { cwd: appDir, stdio: 'ignore', timeout: 300000 });
96
+ if (result.error || result.status !== 0) {
97
+ note();
98
+ }
85
99
  }
86
100
  catch (_a) {
87
- logInfo === null || logInfo === void 0 ? void 0 : logInfo('playwright install chromium failed — continuing (the suite reports missing browsers itself).');
101
+ note();
88
102
  }
89
103
  }
90
104
  const TEST_API_LOG = 'api.test.log';
@@ -99,12 +113,13 @@ const TEST_PORT_BASE = 4500;
99
113
  * `systemSetup.initialAdmin.*` and seeds the admin ONCE on an empty DB (it never
100
114
  * overwrites an existing admin), so a set-up system exists before the suite runs.
101
115
  * A fresh template project's standard auth E2E specs assume such a system — they
102
- * do no self-setup — so without this they fail LOCALLY on the empty per-run DB.
116
+ * do no self-setup — so without this they fail LOCALLY on a test DB that has no
117
+ * users yet (its first run, or after a global-setup reset it).
103
118
  * These are the exact values the lt-monorepo template CI uses
104
119
  * (`.gitlab-ci.yml` / `.github/workflows/test.yml`), keeping CI ↔ local parity.
105
120
  *
106
- * Injected ONLY into the `lt dev test` API process (its DB is fresh + discarded
107
- * per run), NEVER into `lt dev up` — no surprise admin in the persistent dev DB.
121
+ * Injected ONLY into the `lt dev test` API process (its DB is the dedicated
122
+ * `<…>-test` one), NEVER into `lt dev up` — no surprise admin in the dev DB.
108
123
  */
109
124
  exports.TEST_INITIAL_ADMIN_ENV = {
110
125
  NSC__SYSTEM_SETUP__INITIAL_ADMIN__EMAIL: 'ci-admin@test.com',
@@ -235,11 +250,14 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
235
250
  taken.add(apiPort);
236
251
  appPort = layout.appDir ? (0, dev_state_1.allocateInternalPort)(TEST_PORT_BASE, taken) : undefined;
237
252
  const portsToCheck = [apiPort, appPort].filter((p) => typeof p === 'number');
238
- const snap = yield (0, dev_process_1.listenSnapshot)(portsToCheck);
253
+ const probe = yield (0, dev_process_1.probePorts)(portsToCheck);
239
254
  for (const p of portsToCheck) {
240
- const r = snap.get(p);
241
- if (r)
242
- throw new Error(`test internal port ${p} already in use by ${r.command} (pid ${r.pid}).`);
255
+ if (!probe.bound.has(p))
256
+ continue;
257
+ const owner = probe.owners.get(p);
258
+ throw new Error(owner
259
+ ? `test internal port ${p} already in use by ${owner.command} (pid ${owner.pid}).`
260
+ : `test internal port ${p} already in use by an unidentified process.`);
243
261
  }
244
262
  // Reserve immediately (still under the lock) so a concurrent run sees these
245
263
  // ports as taken. PIDs are written to the session file after spawn, below.
@@ -276,7 +294,11 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
276
294
  log.info(` app: ${appUrl} → 127.0.0.1:${appPort}`);
277
295
  if (apiUrl)
278
296
  log.info(` api: ${apiUrl} → 127.0.0.1:${apiPort}`);
279
- log.info(` db: mongodb://127.0.0.1/${dbName} (reset before the suite by Playwright global-setup)`);
297
+ // No claim about resetting here: the CLI never drops this DB, and whether a
298
+ // project's global-setup does is the project's business. The claim used to be
299
+ // printed unconditionally and sent a diagnosis looking for a reset that never
300
+ // happened instead of at the un-migrated DB (DEV-3289).
301
+ log.info(` db: mongodb://127.0.0.1/${dbName}`);
280
302
  log.info('');
281
303
  const devEnv = (0, dev_env_1.buildDevEnv)({
282
304
  apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
@@ -294,43 +316,23 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
294
316
  // hands it to the Playwright child as well.
295
317
  const appEnv = buildTestAppEnv(devEnv.app.env);
296
318
  const pids = {};
297
- // --- API: compiled (`node dist`) for stability; fall back to the project's
298
- // own dev start script. `skipBuild` (sibling shards) reuses the dist the
299
- // first shard produced. Per-component PM detection mirrors `lt dev up`:
300
- // a monorepo with an npm api and a pnpm app must drive each correctly. ---
319
+ // --- API: compiled (`node dist`) for stability, migrated first; falls back to
320
+ // the project's own start script (see `startTestApi`). ---
301
321
  if (layout.apiDir && apiPort) {
302
- const apiPm = (0, dev_package_manager_1.pickPackageManager)(layout.apiDir);
303
- let build = 0;
304
- if (!skipBuild) {
305
- log.info(log.dim('Building API (compiled, for stable long runs) …'));
306
- build = yield (0, dev_process_1.runChildInherit)(apiPm.bin, apiPm.runScript('build'), { cwd: layout.apiDir, env: process.env });
307
- }
308
- const entry = (0, dev_api_launch_1.findCompiledEntry)(layout.apiDir);
309
- // Seed a throwaway initial admin into the fresh, isolated test DB so the
310
- // standard auth E2E specs run against a set-up system — locally exactly like
311
- // the lt-monorepo template CI. Defaults first so an explicitly inherited
322
+ // Seed a throwaway initial admin into the isolated test DB so the standard
323
+ // auth E2E specs run against a set-up system — locally exactly like the
324
+ // lt-monorepo template CI. Defaults first so an explicitly inherited
312
325
  // `NSC__…INITIAL_ADMIN__…` still wins (deliberate override respected). Only
313
326
  // reached from `lt dev test` — `lt dev up` never calls bringUpTestSession.
314
327
  const apiEnv = Object.assign(Object.assign(Object.assign({}, exports.TEST_INITIAL_ADMIN_ENV), devEnv.api.env), { NODE_ENV: 'local' });
315
- let apiSpawn;
316
- if (build === 0 && entry) {
317
- // Runtime follows the PROJECT, never a hardcoded default: a `nest-base` API
318
- // bundles with `Bun.build({ target: 'bun' })` and dies under node with
319
- // "__require is not a function" (DEV-3208).
320
- apiSpawn = (0, dev_process_1.spawnDetached)((0, dev_api_launch_1.resolveApiRuntime)(layout.apiDir), [entry], {
321
- cwd: layout.apiDir,
322
- env: apiEnv,
323
- logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
324
- });
325
- }
326
- else {
327
- log.warn(`compiled API not available — falling back to \`${apiPm.bin} start\` (ts-node).`);
328
- apiSpawn = (0, dev_process_1.spawnDetached)(apiPm.bin, apiPm.runScript('start'), {
329
- cwd: layout.apiDir,
330
- env: apiEnv,
331
- logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
332
- });
333
- }
328
+ const apiSpawn = yield startTestApi({
329
+ apiDir: layout.apiDir,
330
+ apiEnv,
331
+ dbName,
332
+ log,
333
+ logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
334
+ skipBuild,
335
+ });
334
336
  if (apiSpawn)
335
337
  pids.api = apiSpawn.pid;
336
338
  }
@@ -416,10 +418,19 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
416
418
  // left to wait for. Being wrong here costs nothing — the timeout still bounds
417
419
  // the wait, so this only ever makes the abort arrive sooner.
418
420
  const gone = (pid) => pid === undefined || !(0, dev_state_1.isPidAlive)(pid);
421
+ // The probes go to the LOOPBACK address, not the public name: this is Node
422
+ // asking, and on Windows `*.localhost` does not resolve for Node at all — the
423
+ // waits would time out against a stack that is up, and `bringUpTestSession`
424
+ // aborts before Playwright ever starts. Probing the port directly also measures
425
+ // the component rather than the proxy, which is the better question here.
426
+ // The public name stays in the MESSAGES, because that is what the developer
427
+ // opens; the loopback pair is printed next to it above.
428
+ const appProbeUrl = (0, dev_env_1.internalUrl)(appPort) || appUrl;
429
+ const apiProbeUrl = (0, dev_env_1.internalUrl)(apiPort) || apiUrl;
419
430
  // Wait for the test App to answer.
420
431
  if (appUrl) {
421
432
  log.info(log.dim(`Waiting for ${appUrl} …`));
422
- const appReady = yield (0, dev_process_1.waitForHttp)(appUrl, 90000, isStackServing, () => gone(pids.app));
433
+ const appReady = yield (0, dev_process_1.waitForHttp)(appProbeUrl, 90000, isStackServing, () => gone(pids.app));
423
434
  if (!appReady)
424
435
  throw unreachableStackError('App', appUrl, appLogPath);
425
436
  }
@@ -429,7 +440,7 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
429
440
  // `ensureApiReachableOrSkip` guard (the API-readiness race).
430
441
  if (apiUrl) {
431
442
  log.info(log.dim(`Waiting for ${apiUrl}/meta …`));
432
- const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, isStackServing, () => gone(pids.api));
443
+ const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiProbeUrl}/meta`, 120000, isStackServing, () => gone(pids.api));
433
444
  if (!apiReady)
434
445
  throw unreachableStackError('API', `${apiUrl}/meta`, apiLogPath);
435
446
  }
@@ -515,6 +526,19 @@ function hasTestSession(root) {
515
526
  function isStackServing(status) {
516
527
  return status > 0 && ![502, 503, 504].includes(status);
517
528
  }
529
+ /**
530
+ * The abort thrown when the test DB cannot be migrated.
531
+ *
532
+ * An abort rather than a warning for the same reason as `unreachableStackError`:
533
+ * a suite run on an un-migrated DB tests a data state no environment has, and
534
+ * whatever it reports is about the test setup, not about the feature. Unlike the
535
+ * API's boot, `migrate:up` runs in the foreground, so its output is already on
536
+ * screen right above this message.
537
+ */
538
+ function migrationFailedError(dbName, exitCode) {
539
+ return new Error(`migrate:up failed (exit ${String(exitCode)}) against the test DB "${dbName}" — test API not started, ` +
540
+ 'because a suite on an un-migrated DB tests a state no environment has. The migration output is above.');
541
+ }
518
542
  /** Build the dedicated test identity + test DB name for a project. */
519
543
  function resolveTestSession(layout, baseIdentity, shardIndex, devDbName) {
520
544
  const names = testStackNames(shardIndex);
@@ -599,6 +623,50 @@ function runShardedTestSession(layout, baseIdentity, log, opts) {
599
623
  function shardReportDir(root, shardIndex) {
600
624
  return (0, path_1.join)(root, '.lt-dev', `shard.${shardIndex}.playwright-report`);
601
625
  }
626
+ /**
627
+ * Start the test stack's API: build → apply pending migrations → start the
628
+ * compiled bundle, or fall back to the project's own `start` script when no
629
+ * bundle is available.
630
+ *
631
+ * The compiled start bypasses that `start` script and therefore the
632
+ * `migrate:up` it chains in front of the server. The test DB outlives every
633
+ * run — the CLI never drops it — so without the explicit step here it drifted
634
+ * behind every environment that does migrate (DEV-3289). The migration runs
635
+ * with `apiEnv`, the API's own env, so both resolve the same database, and
636
+ * before the spawn, because the API syncs its indexes against whatever it finds
637
+ * on boot. A failed migration throws `migrationFailedError`.
638
+ *
639
+ * `skipBuild` (sibling shards) reuses the dist the first shard produced but
640
+ * still migrates: every shard has a database of its own. The fallback does not
641
+ * migrate itself — the `start` script it runs owns that, exactly as under
642
+ * `lt dev up`. Per-directory package manager, like `lt dev up`: a monorepo with
643
+ * an npm API and a pnpm App must drive each with its own.
644
+ */
645
+ function startTestApi(opts) {
646
+ return __awaiter(this, void 0, void 0, function* () {
647
+ const { apiDir, apiEnv, dbName, log, logFile, skipBuild } = opts;
648
+ const apiPm = (0, dev_package_manager_1.pickPackageManager)(apiDir);
649
+ let build = 0;
650
+ if (!skipBuild) {
651
+ log.info(log.dim('Building API (compiled, for stable long runs) …'));
652
+ build = yield (0, dev_process_1.runChildInherit)(apiPm.bin, apiPm.runScript('build'), { cwd: apiDir, env: process.env });
653
+ }
654
+ const entry = (0, dev_api_launch_1.findCompiledEntry)(apiDir);
655
+ if (build === 0 && entry) {
656
+ const migration = yield (0, dev_api_launch_1.applyPendingMigrations)({ apiDir, env: apiEnv, pm: apiPm });
657
+ if (migration.status === 'failed')
658
+ throw migrationFailedError(dbName, migration.exitCode);
659
+ if (migration.status === 'skipped')
660
+ log.info(log.dim(`no migrate:up script — ${dbName} is not migrated`));
661
+ // Runtime follows the PROJECT, never a hardcoded default: a `nest-base` API
662
+ // bundles with `Bun.build({ target: 'bun' })` and dies under node with
663
+ // "__require is not a function" (DEV-3208).
664
+ return (0, dev_process_1.spawnDetached)((0, dev_api_launch_1.resolveApiRuntime)(apiDir), [entry], { cwd: apiDir, env: apiEnv, logFile });
665
+ }
666
+ log.warn(`compiled API not available — falling back to \`${apiPm.bin} start\` (ts-node).`);
667
+ return (0, dev_process_1.spawnDetached)(apiPm.bin, apiPm.runScript('start'), { cwd: apiDir, env: apiEnv, logFile });
668
+ });
669
+ }
602
670
  /**
603
671
  * Tear down the unsharded test stack AND every sharded stack discovered on disk
604
672
  * (`state.test.<i>.json` in `.lt-dev/`). Used by `lt dev test down` so a
@@ -57,6 +57,7 @@ const dev_package_manager_1 = require("./dev-package-manager");
57
57
  const dev_patches_1 = require("./dev-patches");
58
58
  const dev_project_1 = require("./dev-project");
59
59
  const dev_state_1 = require("./dev-state");
60
+ const platform_1 = require("./platform");
60
61
  /** Marker file (under `.lt-dev/`) that tags a worktree with its ticket id. */
61
62
  const TICKET_MARKER = 'ticket';
62
63
  /** Where ticket databases live. Deliberately loopback-only — never a remote/prod host. */
@@ -281,8 +282,19 @@ function gitRefExists(repoDir, ref) {
281
282
  * for fresh scaffolds without a lockfile yet.
282
283
  */
283
284
  function installWorktreeDeps(dir) {
285
+ var _a;
284
286
  const pm = (0, dev_package_manager_1.pickPackageManager)(dir);
285
- (0, child_process_1.execFileSync)(pm.bin, pm.installArgs, { cwd: dir, stdio: 'inherit' });
287
+ // `spawnCmdSync` (spawnSync) does NOT throw on a non-zero exit, unlike the
288
+ // `execFileSync` this replaced. The contract here is to throw: the only caller
289
+ // (`lt ticket start`) catches and warns, and swallowing the failure would hand
290
+ // the developer a half-installed worktree with no hint that anything went wrong.
291
+ const result = (0, platform_1.spawnCmdSync)(pm.bin, pm.installArgs, { cwd: dir, stdio: 'inherit' });
292
+ if (result.error) {
293
+ throw result.error;
294
+ }
295
+ if (result.status !== 0) {
296
+ throw new Error(`${pm.bin} ${pm.installArgs.join(' ')} exited with ${(_a = result.status) !== null && _a !== void 0 ? _a : 'a signal'}`);
297
+ }
286
298
  }
287
299
  /**
288
300
  * True for ticket ids whose derived database would collide with a project-level
@@ -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
@@ -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
  *
@@ -32,11 +32,16 @@ const fs_utils_1 = require("./fs-utils");
32
32
  * starter's assessed-advisory allowlist is destroyed rather than merely ignored,
33
33
  * and the generated project's very first pipeline goes red on an advisory that
34
34
  * was already justified upstream.
35
+ *
36
+ * `peerDependencyRules` (`{ allowedVersions: {...}, ignoreMissing: [...] }`) is
37
+ * the same trap one step later: the starter scope-allows known-stale peer ranges
38
+ * there, and without the hoist the root `check:peers` step fails the first
39
+ * `pnpm run check` of every generated project.
35
40
  */
36
41
  const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
37
42
  const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
38
- /** Objects whose values are arrays to be unioned, not replaced. */
39
- const NESTED_ARRAY_FIELDS = ['auditConfig'];
43
+ /** Objects whose inner arrays are unioned and inner maps merged, not replaced. */
44
+ const NESTED_ARRAY_FIELDS = ['auditConfig', 'peerDependencyRules'];
40
45
  /** The union of all three, in declaration order. Declared here, with its inputs,
41
46
  * because the comment-carrying helpers below default their `fields` parameter to it. */
42
47
  const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS, ...NESTED_ARRAY_FIELDS];
@@ -611,11 +616,13 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
611
616
  const subArr = Array.isArray(subValue) ? subValue : [];
612
617
  return Array.from(new Set([...rootArr, ...subArr])).sort((a, b) => a.localeCompare(b));
613
618
  }
614
- // Nested (`auditConfig.ignoreGhsas` / `.ignoreCves`): union each inner array
615
- // instead of letting the sub-project's object replace the root's. A plain
616
- // key-by-key merge would drop every advisory the root had already justified.
619
+ // Nested (`auditConfig.ignoreGhsas`, `peerDependencyRules.allowedVersions`, …):
620
+ // union each inner array and merge each inner map instead of letting the
621
+ // sub-project's object replace the root's. A plain key-by-key merge would drop
622
+ // every advisory or peer rule the root had already justified.
617
623
  if (isNestedArrayField(field)) {
618
- const asObj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
624
+ const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
625
+ const asObj = (v) => (isMap(v) ? v : {});
619
626
  const rootObj = asObj(rootValue);
620
627
  const subObj = asObj(subValue);
621
628
  const merged = Object.assign({}, rootObj);
@@ -625,6 +632,10 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
625
632
  const b = Array.isArray(value) ? value : [];
626
633
  merged[key] = Array.from(new Set([...a, ...b])).sort((x, y) => x.localeCompare(y));
627
634
  }
635
+ else if (isMap(value) && isMap(merged[key])) {
636
+ const inner = Object.assign(Object.assign({}, merged[key]), value);
637
+ merged[key] = Object.fromEntries(Object.entries(inner).sort(([x], [y]) => x.localeCompare(y)));
638
+ }
628
639
  else {
629
640
  merged[key] = value;
630
641
  }