@lenne.tech/cli 1.41.3 → 1.43.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.
@@ -46,6 +46,7 @@ exports.Server = void 0;
46
46
  const crypto = __importStar(require("crypto"));
47
47
  const path_1 = require("path");
48
48
  const ts = __importStar(require("typescript"));
49
+ const adopt_upstream_build_allowlist_1 = require("../lib/adopt-upstream-build-allowlist");
49
50
  const check_freshness_hooks_1 = require("../lib/check-freshness-hooks");
50
51
  const markdown_table_1 = require("../lib/markdown-table");
51
52
  const strip_comments_1 = require("../lib/strip-comments");
@@ -1021,6 +1022,22 @@ class Server {
1021
1022
  // Best-effort — without the lockfile, the closure step falls back to
1022
1023
  // the upstream package.json ranges (still better than nothing).
1023
1024
  }
1025
+ // Snapshot the upstream pnpm-workspace.yaml. It carries `allowBuilds`, the map
1026
+ // that decides which packages may run install scripts — and vendoring is what
1027
+ // makes the framework's entries the PROJECT's problem: step 5b turns the core's
1028
+ // import closure into direct dependencies, so packages nest-server knew about
1029
+ // (bullmq → msgpackr → msgpackr-extract) become the project's own. pnpm 11 aborts
1030
+ // the install outright on an unlisted one, so a missing entry is an unusable
1031
+ // project rather than a warning. This file is NOT in nest-server's npm tarball,
1032
+ // so the clone is the only moment it can be read at all.
1033
+ let upstreamWorkspaceYaml = '';
1034
+ try {
1035
+ upstreamWorkspaceYaml = filesystem.read(`${tmpClone}/pnpm-workspace.yaml`) || '';
1036
+ }
1037
+ catch (_c) {
1038
+ // Best-effort: an older framework revision may predate the file. The project
1039
+ // then keeps exactly the allowlist its own template shipped.
1040
+ }
1024
1041
  // Snapshot the upstream CLAUDE.md for section-merge into projects/api/CLAUDE.md.
1025
1042
  // The nest-server CLAUDE.md contains framework-specific instructions that
1026
1043
  // Claude Code needs to work correctly with the vendored source (API conventions,
@@ -1033,7 +1050,7 @@ class Server {
1033
1050
  upstreamClaudeMd = claudeMdContent;
1034
1051
  }
1035
1052
  }
1036
- catch (_c) {
1053
+ catch (_d) {
1037
1054
  // Non-fatal — if missing, the project CLAUDE.md just won't get upstream sections.
1038
1055
  }
1039
1056
  // Snapshot the upstream commit SHA for traceability in VENDOR.md.
@@ -1042,7 +1059,7 @@ class Server {
1042
1059
  const sha = yield system.run(`git -C ${tmpClone} rev-parse HEAD`);
1043
1060
  upstreamCommit = (sha || '').trim();
1044
1061
  }
1045
- catch (_d) {
1062
+ catch (_e) {
1046
1063
  // Non-fatal — VENDOR.md will just show an empty SHA.
1047
1064
  }
1048
1065
  try {
@@ -1325,7 +1342,7 @@ class Server {
1325
1342
  }
1326
1343
  }
1327
1344
  }
1328
- catch (_e) {
1345
+ catch (_f) {
1329
1346
  // skip unreadable file
1330
1347
  }
1331
1348
  }
@@ -1503,6 +1520,14 @@ class Server {
1503
1520
  upstreamDeps,
1504
1521
  upstreamDevDeps,
1505
1522
  });
1523
+ // The closure above just made framework-only packages direct dependencies of
1524
+ // this project. Their build-script decisions have to come along, or the first
1525
+ // `pnpm install` stops on ERR_PNPM_IGNORED_BUILDS. Additive only — a decision
1526
+ // the project already made is never overwritten.
1527
+ const adoptedBuilds = (0, adopt_upstream_build_allowlist_1.adoptUpstreamBuildAllowlist)({ dest, filesystem, upstreamWorkspaceYaml });
1528
+ if (adoptedBuilds.length > 0) {
1529
+ this.toolbox.print.info(` vendored core allowlist: adopted ${adoptedBuilds.length} build decision(s) from nest-server → ${adoptedBuilds.join(', ')}`);
1530
+ }
1506
1531
  // Add a script to run the local bin/migrate.js. The starter's
1507
1532
  // existing migrate:* scripts are already correct for npm mode; we
1508
1533
  // need them pointing at the local bin + local ts-compiler.
@@ -0,0 +1,140 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.adoptUpstreamBuildAllowlist = adoptUpstreamBuildAllowlist;
4
+ const js_yaml_1 = require("js-yaml");
5
+ const fs_utils_1 = require("./fs-utils");
6
+ const hoist_workspace_pnpm_config_1 = require("./hoist-workspace-pnpm-config");
7
+ /**
8
+ * Carry the framework's build-script allowlist into a project that has just
9
+ * vendored it.
10
+ *
11
+ * `allowBuilds` decides which packages may run install scripts. pnpm 11 does not
12
+ * treat an unlisted one as "deny" — it ABORTS the install with
13
+ * `ERR_PNPM_IGNORED_BUILDS` and writes a `set this to true or false` placeholder
14
+ * into the workspace file. So a single missing entry is not a hardening gap; it
15
+ * is a project that cannot be installed at all, on the very first
16
+ * `lt fullstack init`, before anyone has written a line of code.
17
+ *
18
+ * Vendoring is exactly where that gap opens. The conversion resolves the core's
19
+ * import closure into DIRECT dependencies — `import('bullmq')` in
20
+ * core-cron-jobs.service.ts becomes a real `bullmq` dep, which pulls
21
+ * `msgpackr` → `msgpackr-extract`, a package with an install script. nest-server
22
+ * knows about it and lists it. The project does not, and cannot: nest-server's
23
+ * `pnpm-workspace.yaml` is not part of its npm tarball — `files` ships `dist`,
24
+ * `src`, `bin` and the docs, never a repo-root config file — so nothing
25
+ * downstream can read it. Until now the only bridge was a
26
+ * human copying entries into nest-server-starter by hand — and that bridge has
27
+ * already been observed to rot: `@scarf/scarf` sat at `true` in the starter while
28
+ * the framework denied it, for long enough that the drift shipped.
29
+ *
30
+ * Deliberately additive. A key the project has already decided is left exactly as
31
+ * it is, including an explicit `false`: the project is the more specific context,
32
+ * and silently flipping its decision to match the framework would be a worse bug
33
+ * than the one this fixes. Only genuinely absent keys are taken over — together
34
+ * with the comment that explains them, because an entry like
35
+ * `'msgpackr-extract': false` reads as dead weight without it and gets deleted by
36
+ * the next person who runs `pnpm why`.
37
+ *
38
+ * @returns the keys actually adopted, for the caller to report.
39
+ */
40
+ function adoptUpstreamBuildAllowlist(options) {
41
+ var _a;
42
+ const { dest, filesystem, upstreamWorkspaceYaml } = options;
43
+ if (!upstreamWorkspaceYaml)
44
+ return [];
45
+ const upstream = parseYamlObject(upstreamWorkspaceYaml);
46
+ const upstreamAllow = asStringBoolMap(upstream === null || upstream === void 0 ? void 0 : upstream.allowBuilds);
47
+ if (Object.keys(upstreamAllow).length === 0)
48
+ return [];
49
+ // Never write through a symlinked project: with `--api-link` it points at the
50
+ // user's own nest-server-starter checkout, and this would edit their repo. The
51
+ // same guard already protects `hoistWorkspacePnpmConfig` and
52
+ // `removeNestedLockfiles`; these two libs were the odd ones out.
53
+ if ((0, fs_utils_1.isSymlink)(dest))
54
+ return [];
55
+ const destPath = `${dest}/pnpm-workspace.yaml`;
56
+ // No file to extend means no pnpm settings of the project's own. Writing one
57
+ // here would invent a workspace root the scaffolding did not ask for, so the
58
+ // absence is respected rather than filled in.
59
+ if (!filesystem.exists(destPath))
60
+ return [];
61
+ const destRaw = (_a = filesystem.read(destPath)) !== null && _a !== void 0 ? _a : '';
62
+ const destWs = parseYamlObject(destRaw);
63
+ if (!destWs)
64
+ return [];
65
+ // The project's map is read RAW, not through `asStringBoolMap`. Narrowing it to
66
+ // booleans first was a live deny-bypass with no attacker involved: js-yaml 4
67
+ // uses the YAML 1.2 core schema, so `esbuild: no` and `esbuild: off` parse as
68
+ // STRINGS. A maintainer writing `no` to mean "deny" was read as "no opinion",
69
+ // upstream's `true` was adopted, and the original entry was dropped from the
70
+ // rewritten map as well. Same for `'false'`, for an empty value (null), and for
71
+ // pnpm's own `set this to true or false` placeholder — all five verified.
72
+ //
73
+ // `Object.create(null)` and `hasOwnProperty.call`: with a plain object,
74
+ // `'constructor' in map` is true via the prototype, so eight real package names
75
+ // ('constructor', 'toString', 'valueOf', …) would be treated as already decided
76
+ // and silently never adopted — the ERR_PNPM_IGNORED_BUILDS abort this whole
77
+ // function exists to prevent.
78
+ const merged = Object.assign(Object.create(null), asRawMap(destWs.allowBuilds));
79
+ const adopted = [];
80
+ for (const [pkg, value] of Object.entries(upstreamAllow)) {
81
+ // ANY existing key is a decision, whatever shape YAML gave it.
82
+ if (Object.prototype.hasOwnProperty.call(merged, pkg))
83
+ continue;
84
+ merged[pkg] = value;
85
+ adopted.push(pkg);
86
+ }
87
+ if (adopted.length === 0)
88
+ return [];
89
+ destWs.allowBuilds = Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
90
+ // The project's own annotations win where both files comment the same key; the
91
+ // upstream ones fill in only for the keys just adopted, which by definition the
92
+ // project had nothing to say about.
93
+ const comments = (0, hoist_workspace_pnpm_config_1.extractKeyComments)(destRaw);
94
+ for (const [key, block] of (0, hoist_workspace_pnpm_config_1.extractKeyComments)(upstreamWorkspaceYaml)) {
95
+ if (!comments.has(key))
96
+ comments.set(key, block);
97
+ }
98
+ filesystem.write(destPath, (0, hoist_workspace_pnpm_config_1.reattachKeyComments)((0, js_yaml_1.dump)(destWs, { lineWidth: -1, sortKeys: false }), comments));
99
+ return adopted;
100
+ }
101
+ /** The value as a plain key/value map, or an empty one — no narrowing of values. */
102
+ function asRawMap(value) {
103
+ if (!value || typeof value !== 'object' || Array.isArray(value))
104
+ return {};
105
+ return value;
106
+ }
107
+ /**
108
+ * Narrow an unknown value to a `{ pkg: boolean }` map, dropping other shapes.
109
+ *
110
+ * Used for the UPSTREAM side only. There, dropping a non-boolean is right — we
111
+ * adopt only decisions we understand. On the DEST side it is the opposite: see
112
+ * the comment at the merge above.
113
+ */
114
+ function asStringBoolMap(value) {
115
+ if (!value || typeof value !== 'object' || Array.isArray(value))
116
+ return {};
117
+ const out = {};
118
+ for (const [key, v] of Object.entries(value)) {
119
+ if (typeof v === 'boolean')
120
+ out[key] = v;
121
+ }
122
+ return out;
123
+ }
124
+ /** Parse YAML into a plain object, or null on empty/malformed/non-object input. */
125
+ function parseYamlObject(raw) {
126
+ if (!raw)
127
+ return null;
128
+ let parsed;
129
+ try {
130
+ parsed = (0, js_yaml_1.load)(raw);
131
+ }
132
+ catch (_a) {
133
+ // Malformed upstream YAML must not take the conversion down with it — the
134
+ // vendored project is still usable, it just does not inherit the allowlist.
135
+ return null;
136
+ }
137
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
138
+ return null;
139
+ return parsed;
140
+ }
@@ -9,17 +9,19 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  });
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.TEST_INITIAL_ADMIN_ENV = void 0;
12
+ exports.TEST_NITRO_OUTPUT_DIR = exports.TEST_NUXT_BUILD_DIR = exports.TEST_INITIAL_ADMIN_ENV = void 0;
13
13
  exports.ensurePlaywrightBrowsers = ensurePlaywrightBrowsers;
14
14
  exports.autoShardCount = autoShardCount;
15
15
  exports.bringUpTestSession = bringUpTestSession;
16
16
  exports.buildShardPlaywrightInvocation = buildShardPlaywrightInvocation;
17
+ exports.buildTestAppEnv = buildTestAppEnv;
17
18
  exports.hasTestSession = hasTestSession;
18
19
  exports.resolveTestSession = resolveTestSession;
19
20
  exports.runShardedTestSession = runShardedTestSession;
20
21
  exports.shardReportDir = shardReportDir;
21
22
  exports.tearDownAllTestSessions = tearDownAllTestSessions;
22
23
  exports.tearDownTestSession = tearDownTestSession;
24
+ exports.testAppEntryCandidates = testAppEntryCandidates;
23
25
  /**
24
26
  * Ephemeral, isolated test session for `lt dev test`.
25
27
  *
@@ -103,6 +105,47 @@ exports.TEST_INITIAL_ADMIN_ENV = {
103
105
  NSC__SYSTEM_SETUP__INITIAL_ADMIN__NAME: 'CI Admin',
104
106
  NSC__SYSTEM_SETUP__INITIAL_ADMIN__PASSWORD: 'CiThrowawayAdmin123!',
105
107
  };
108
+ /**
109
+ * The Nuxt build directory the test stack's app process uses — never the one
110
+ * `nuxt dev` / the IDE write (`.nuxt`), never the check chain's (`.nuxt-check`).
111
+ *
112
+ * `@nuxt/cli` takes its lock ON the build directory
113
+ * (`acquireLock(nuxt.options.buildDir)`), so sharing it does not merely
114
+ * interleave writes — it makes the second command ABORT: a `lt dev test` next to
115
+ * a parked `lt dev up` died with "Another Nuxt dev is already running", the test
116
+ * app never started, and every spec then failed on a missing selector. That
117
+ * reads like broken specs while being pure infrastructure, which is what made it
118
+ * expensive to diagnose. A directory of its own frees the lock and the writes in
119
+ * one move.
120
+ *
121
+ * Projects whose `nuxt.config.ts` does not (yet) read `NUXT_BUILD_DIR` simply
122
+ * ignore it, so this needs no per-project case distinction.
123
+ */
124
+ exports.TEST_NUXT_BUILD_DIR = '.nuxt-test';
125
+ /**
126
+ * The Nitro OUTPUT directory the test stack builds into (DEV-2724).
127
+ *
128
+ * A second axis from `TEST_NUXT_BUILD_DIR`, not a duplicate of it: `buildDir`
129
+ * and Nitro's `output.dir` are unrelated knobs, so isolating the former left
130
+ * `.output/` shared. That matters here more than anywhere else, because this
131
+ * stack does not run `nuxt dev` — it serves the production bundle, rebuilding on
132
+ * every run, and therefore overwrites the tree a local `pnpm run build` (or a
133
+ * server started from it) is using.
134
+ *
135
+ * NEITHER variable is framework-native — verified against `@nuxt/schema` 4.4.8,
136
+ * `nitropack` 2.13.4 and `c12`: none of them reads `NUXT_BUILD_DIR` or
137
+ * `NITRO_OUTPUT_DIR`. Both levers are opened by the project's own
138
+ * `nuxt.config.ts` (`buildDir: process.env.NUXT_BUILD_DIR || '.nuxt'`,
139
+ * `nitro.output.dir: process.env.NITRO_OUTPUT_DIR || '.output'`); nuxt-base-starter
140
+ * ≥ 2.16.0 ships both. Do NOT write "unlike NUXT_BUILD_DIR, …" here: that
141
+ * asymmetric contrast silently promotes one of them to a framework feature and
142
+ * makes readers forward only the other, which reintroduces the collision this
143
+ * whole mechanism exists to prevent.
144
+ *
145
+ * Projects that forward neither simply keep building into `.nuxt` / `.output`,
146
+ * which is why `testAppEntryCandidates()` still looks there.
147
+ */
148
+ exports.TEST_NITRO_OUTPUT_DIR = '.output-test';
106
149
  /**
107
150
  * Heuristic for the default local shard count (`--shard auto` / bare `--shard`).
108
151
  *
@@ -236,6 +279,11 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
236
279
  dbName,
237
280
  identity: testIdentity,
238
281
  });
282
+ // Every app process below — the build, the built server, and the dev-server
283
+ // fallback — runs with THIS env, so the test stack never touches the build dir
284
+ // `nuxt dev` and the IDE use. Declared out here because the session context
285
+ // hands it to the Playwright child as well.
286
+ const appEnv = buildTestAppEnv(devEnv.app.env);
239
287
  const pids = {};
240
288
  // --- API: compiled (`node dist`) for stability; fall back to the project's
241
289
  // own dev start script. `skipBuild` (sibling shards) reuses the dist the
@@ -281,25 +329,41 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
281
329
  // buildDevEnv sets NUXT_PUBLIC_API_PROXY=false, so the built app talks
282
330
  // cross-origin to the test API exactly like prod (the injected session cookie
283
331
  // must be a cross-subdomain DOMAIN cookie — see the project's parseCookieHeader).
284
- // Rebuilt every run so the suite never hits stale code (no build-skip / reuse). ---
332
+ // Rebuilt every run so the suite never hits stale code (no build-skip / reuse).
333
+ // `appEnv` pins NUXT_BUILD_DIR to its own dir, which is what lets this run next
334
+ // to a parked `lt dev up` at all: the Nuxt lock sits on the build dir, so the
335
+ // build below used to abort outright against a running dev server (DEV-2715).
336
+ // The fallback needs it just as much — that path IS a second `nuxt dev`.
337
+ // It also pins NITRO_OUTPUT_DIR (DEV-2724), so the rebuild below stops
338
+ // overwriting the `.output/` a local `pnpm run build` is serving — and the
339
+ // entry lookup moves with it, or the spawn would find nothing and drop to the
340
+ // slow `pnpm dev` fallback. ---
285
341
  if (layout.appDir && appPort) {
342
+ // The isolated build trees are new names this CLI invented, so a project
343
+ // whose `.gitignore` predates the starter's `.nuxt-*` / `.output-*` globs
344
+ // leaves them UNTRACKED — a `git add -A` away from committing a Nitro
345
+ // bundle (which inlines runtimeConfig defaults), and enough to make
346
+ // `lt ticket stop` see uncommitted work. Idempotent, like every other
347
+ // `addToGitignore` call.
348
+ (0, dev_patches_1.addToGitignore)(layout.appDir, '.nuxt-*');
349
+ (0, dev_patches_1.addToGitignore)(layout.appDir, '.output-*');
286
350
  const appPm = (0, dev_package_manager_1.pickPackageManager)(layout.appDir);
287
351
  let appBuild = 0;
288
352
  if (!skipBuild) {
289
353
  log.info(log.dim('Building App (nuxt build, for speed + prod-fidelity) …'));
290
354
  appBuild = yield (0, dev_process_1.runChildInherit)(appPm.bin, appPm.runScript('build'), {
291
355
  cwd: layout.appDir,
292
- env: devEnv.app.env,
356
+ env: appEnv,
293
357
  });
294
358
  }
295
- const appEntry = ['.output/server/index.mjs']
359
+ const appEntry = testAppEntryCandidates()
296
360
  .map((rel) => (0, path_1.join)(layout.appDir, rel))
297
361
  .find((p) => (0, fs_1.existsSync)(p));
298
362
  let appSpawn;
299
363
  if (appBuild === 0 && appEntry) {
300
364
  appSpawn = (0, dev_process_1.spawnDetached)('node', [appEntry], {
301
365
  cwd: layout.appDir,
302
- env: devEnv.app.env,
366
+ env: appEnv,
303
367
  logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
304
368
  });
305
369
  }
@@ -307,7 +371,7 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
307
371
  log.warn(`built app not available — falling back to \`${appPm.bin} dev\` (slower: cold-compiles routes).`);
308
372
  appSpawn = (0, dev_process_1.spawnDetached)(appPm.bin, appPm.runScript('dev'), {
309
373
  cwd: layout.appDir,
310
- env: devEnv.app.env,
374
+ env: appEnv,
311
375
  logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
312
376
  });
313
377
  }
@@ -340,7 +404,7 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
340
404
  // Expose THIS stack's API log path so the caller can point the auth E2E specs
341
405
  // (via NEST_SERVER_LOG) at the exact isolated log — correct per shard.
342
406
  const apiLogPath = layout.apiDir ? (0, path_1.join)(layout.root, '.lt-dev', names.apiLog) : undefined;
343
- return { apiLogPath, apiUrl, appEnv: devEnv.app.env, appUrl, dbName, pids, testIdentity };
407
+ return { apiLogPath, apiUrl, appEnv, appUrl, dbName, pids, testIdentity };
344
408
  });
345
409
  }
346
410
  /**
@@ -382,6 +446,20 @@ function buildShardPlaywrightInvocation(pm, shardIndex, total, forwarded, htmlRe
382
446
  },
383
447
  };
384
448
  }
449
+ /**
450
+ * The env the test stack's APP process runs with: the isolated dev env plus its
451
+ * own Nuxt build dir.
452
+ *
453
+ * The pin goes LAST on purpose — unlike `TEST_INITIAL_ADMIN_ENV`, which is
454
+ * spread first so a deliberately inherited credential still wins. Here the
455
+ * isolation IS the contract: `buildDevEnv` seeds the app env from `process.env`,
456
+ * so a shell that exports `NUXT_BUILD_DIR` (left over from debugging a check
457
+ * run, say) would otherwise hand the test stack the dev — or the gate — dir
458
+ * straight back, and the collision returns silently.
459
+ */
460
+ function buildTestAppEnv(appEnv) {
461
+ return Object.assign(Object.assign({}, appEnv), { NITRO_OUTPUT_DIR: exports.TEST_NITRO_OUTPUT_DIR, NUXT_BUILD_DIR: exports.TEST_NUXT_BUILD_DIR });
462
+ }
385
463
  /** True when a test session file exists (used by status/down). */
386
464
  function hasTestSession(root) {
387
465
  return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
@@ -541,11 +619,45 @@ function tearDownTestSession(layout_1, baseIdentity_1, log_1) {
541
619
  delete reg.projects[testIdentity.slug];
542
620
  (0, dev_state_1.saveRegistry)(reg);
543
621
  }
622
+ // Reclaim the isolated build trees. They exist only for this stack and are
623
+ // rebuilt from scratch on every run (there is no build-skip / reuse), so
624
+ // keeping them buys nothing and costs 37-294 MB per project — permanently,
625
+ // since nothing else ever removes them. Only the SUFFIXED names are touched:
626
+ // a project that does not forward the env vars builds into the shared
627
+ // `.nuxt` / `.output`, which belong to the developer's own `pnpm run build`.
628
+ if (layout.appDir) {
629
+ for (const dir of [exports.TEST_NUXT_BUILD_DIR, exports.TEST_NITRO_OUTPUT_DIR]) {
630
+ const path = (0, path_1.join)(layout.appDir, dir);
631
+ try {
632
+ if ((0, fs_1.existsSync)(path)) {
633
+ (0, fs_1.rmSync)(path, { force: true, recursive: true });
634
+ stopped.push(`${dir}/`);
635
+ }
636
+ }
637
+ catch (_a) {
638
+ // Best effort — a locked build tree must not fail the teardown.
639
+ }
640
+ }
641
+ }
544
642
  if (!opts.silent && stopped.length > 0)
545
643
  log.info(`Stopped test stack: ${stopped.join(', ')}`);
546
644
  return { stopped };
547
645
  });
548
646
  }
647
+ /**
648
+ * Where to look for the built server entry, in priority order.
649
+ *
650
+ * The isolated dir comes FIRST and that ordering is load-bearing: `.find()` takes
651
+ * the first hit and a stale `.output/` from an earlier local build is the normal
652
+ * case, so the shared path first would serve that stale bundle while the fresh
653
+ * build sat unused. The shared path remains as a fallback for projects whose
654
+ * `nuxt.config.ts` does not forward `NITRO_OUTPUT_DIR` yet — without it they
655
+ * would find no entry at all and drop to the slow `pnpm dev` fallback, which is
656
+ * a second `nuxt dev` and re-takes the build-dir lock DEV-2715 just freed.
657
+ */
658
+ function testAppEntryCandidates() {
659
+ return [`${exports.TEST_NITRO_OUTPUT_DIR}/server/index.mjs`, '.output/server/index.mjs'];
660
+ }
549
661
  /**
550
662
  * Resolve the per-stack file/identity names. For a sharded run (`shardIndex`
551
663
  * given) everything gets a `.<i>` / `-<i>` suffix so N stacks coexist without
@@ -587,8 +587,23 @@ function runWithProjectDriver(driverPaths, action, env) {
587
587
  return { outcome: 'failed', stdout: '' };
588
588
  }
589
589
  }
590
- /** Framework-generated / ephemeral paths a dev/build run dirties (never real work). */
591
- const GENERATED_PATHS = /(^|\/)(\.nuxtrc|\.nuxt|\.nitro|\.output|dist|\.turbo|\.cache|\.eslintcache)(\/|$)|\.tsbuildinfo$/;
590
+ /**
591
+ * Framework-generated / ephemeral paths a dev/build run dirties (never real work).
592
+ *
593
+ * `.nuxt` and `.output` carry an optional `-<suffix>`: the build directory is no
594
+ * longer a single well-known name. The check chain builds into `.nuxt-check` and
595
+ * `lt dev test` into `.nuxt-test` / `.output-test`, precisely so they do not
596
+ * collide with a parked `nuxt dev` (the Nuxt lock sits on the build dir).
597
+ *
598
+ * Without the suffix these read as REAL developer work — a glob segment matches
599
+ * whole segments, so `.nuxt` never covered `.nuxt-test` — and
600
+ * `worktreeSafetyReport` then classifies a 300 MB build tree as uncommitted
601
+ * work, making `lt ticket stop` REFUSE to remove the worktree over files the
602
+ * developer never wrote. Only projects whose app `.gitignore` predates the
603
+ * starter's `.nuxt-*` globs are affected, but that is exactly the population the
604
+ * rest of this CLI's heal machinery exists to serve.
605
+ */
606
+ const GENERATED_PATHS = /(^|\/)(\.nuxtrc|\.nuxt(-[\w.]+)?|\.nitro|\.output(-[\w.]+)?|dist|\.turbo|\.cache|\.eslintcache)(\/|$)|\.tsbuildinfo$/;
592
607
  /** The three git-tracked configs `lt dev up` self-heals to be env-aware. */
593
608
  const LT_DEV_MANAGED_CONFIG = /(?:^|\/)(?:config\.env\.ts|nuxt\.config\.ts|playwright\.config\.ts)$/;
594
609
  /**
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.failRun = failRun;
4
+ /**
5
+ * Mark the current command run as failed.
6
+ *
7
+ * A gluegun command signals failure by printing and returning, and a bare
8
+ * `return` leaves the process at exit code 0. So a scaffold that died halfway
9
+ * reported SUCCESS to every caller that checks `$?` — a CI job, a wrapper
10
+ * script, an agent. That is not theoretical: a `lt fullstack init` whose
11
+ * `pnpm install` aborted on a native build script printed a red spinner and
12
+ * still exited 0, and the half-built workspace was only noticed later, by hand.
13
+ *
14
+ * Call it immediately before every `return` on an error path:
15
+ *
16
+ * failRun(toolbox);
17
+ * return;
18
+ *
19
+ * **`process.exitCode`, not `process.exit()`** — the latter can truncate
20
+ * buffered output, including the spinner's own failure message, which is the one
21
+ * line the operator actually needs.
22
+ *
23
+ * **Guarded by `fromGluegunMenu`**, like the CLI's other exit-code call sites
24
+ * (`dev test`, `dev tunnel`, `tools ocr`, `workspace-integration`): inside the
25
+ * interactive `lt` menu a command is one step of a longer session, and failing
26
+ * the whole session because one step errored is the same over-reach in reverse.
27
+ *
28
+ * Shared rather than redeclared per command: `fullstack init` delegates to
29
+ * `add-api` / `add-app` inside an existing workspace, so all three have to agree
30
+ * on the contract or the exit code depends on which directory the user happened
31
+ * to be standing in — which was exactly the state before this helper existed.
32
+ */
33
+ function failRun(toolbox) {
34
+ var _a, _b;
35
+ if (!((_b = (_a = toolbox.parameters) === null || _a === void 0 ? void 0 : _a.options) === null || _b === void 0 ? void 0 : _b.fromGluegunMenu)) {
36
+ process.exitCode = 1;
37
+ }
38
+ }