@lenne.tech/cli 1.41.2 → 1.42.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.
@@ -268,7 +268,24 @@ const NewCommand = {
268
268
  info(' (an explicit node blocks the parent wildcard for names below it)');
269
269
  info(' 4. Set the stage env vars in TurboOps (per stage), e.g. for production:');
270
270
  info(` NODE_ENV=production, NSC__BASE_URL=https://api.${domain},`);
271
- info(' NSC__MONGOOSE__URI, NSC__BETTER_AUTH__SECRET, NSC__AI__ENCRYPTION_SECRET,');
271
+ // The DB host is spelled out per stage on purpose. Every other variable in
272
+ // this checklist carries a concrete value; leaving this one as a bare name
273
+ // forces the reader to invent it, and the only reference in sight is the
274
+ // project's own docker-compose.yml, where the service is called `mongo`.
275
+ // `mongodb://mongo:27017/...` is the natural guess — and the wrong one: the
276
+ // short name is a Swarm alias on a network shared by every stack, so it
277
+ // resolves to a FOREIGN project's database (and to a different one on each
278
+ // connection). Symptoms are split-brain writes, sessions that vanish, and
279
+ // data quietly landing in someone else's MongoDB. See DEV-2140.
280
+ info(` NSC__MONGOOSE__URI=mongodb://<user>:<pass>@${project}-production_mongo:27017/${project}?authSource=admin,`);
281
+ info(` (dev stage: mongodb://<user>:<pass>@${project}-dev_mongo:27017/${project}?authSource=admin)`);
282
+ info(' NOTE: always the stack-prefixed host `<project>-<stage>_mongo`, never a bare');
283
+ info(' `mongo` — the short name is shared across stacks and resolves to a FOREIGN');
284
+ info(' database, non-deterministically per connection.');
285
+ info(' The stack-prefix fixes WHICH database you reach, not WHO may reach it:');
286
+ info(' the overlay network is shared, so the DB credentials are the actual');
287
+ info(' boundary. Set them in the mongo service and never deploy it open.');
288
+ info(' NSC__BETTER_AUTH__SECRET, NSC__AI__ENCRYPTION_SECRET,');
272
289
  if (isAngular) {
273
290
  info(' NSC__EMAIL__SMTP__*, NSC__EMAIL__DEFAULT_SENDER__EMAIL');
274
291
  info(' The Angular app needs no URL env vars — they are baked into');
@@ -0,0 +1,173 @@
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.help = void 0;
13
+ const vscode_settings_1 = require("../../lib/vscode-settings");
14
+ /**
15
+ * Tune VS Code's USER settings for machines that keep many lt monorepos open.
16
+ *
17
+ * Each open workspace root spawns its own pair of TypeScript servers, and the
18
+ * "semantic" one of each pair is what actually holds the memory. Eight open
19
+ * monorepos (api + app root each) therefore means 16 semantic servers — enough
20
+ * to push a 32 GB machine deep into swap. This command applies the verified
21
+ * profile in `lib/vscode-settings.ts` to every detected installation.
22
+ *
23
+ * Safety properties, all load-bearing:
24
+ * - JSONC-aware, so comments and formatting in a hand-maintained
25
+ * settings.json survive (a JSON.parse round-trip would delete them).
26
+ * - Refuses to write into a settings.json it cannot parse.
27
+ * - Backs up to `settings.json.bak` before the first write.
28
+ * - Merges the object-valued exclude maps, so hand-added entries are kept —
29
+ * and `--revert` SUBTRACTS only those same entries again, so an undo never
30
+ * takes a hand-maintained exclusion with it.
31
+ * - Keeps the FIRST `.bak`, so a later run (including the revert) cannot
32
+ * overwrite the record of the pre-tuning state.
33
+ * - No-op on re-run.
34
+ *
35
+ * `--revert` restores VS Code's default for the scalar keys rather than any
36
+ * explicit value that preceded them; the `.bak` is the recovery path for those.
37
+ */
38
+ const VsCodeCommand = {
39
+ alias: ['vsc'],
40
+ description: 'Tune VS Code memory settings',
41
+ hidden: false,
42
+ name: 'vscode',
43
+ run: (toolbox) => __awaiter(void 0, void 0, void 0, function* () {
44
+ const { parameters, print: { colors, info }, prompt: { confirm }, } = toolbox;
45
+ // `--dry-run` PREVENTS a write, so it reads presence-as-intent: `--dry-run=1`
46
+ // must not fall through and write. `--revert` / `--explain` merely enable
47
+ // something, so the usual `=== true || === 'true'` is safe there.
48
+ const dryRun = (0, vscode_settings_1.isPreventingFlagSet)(parameters.options, 'dry-run', 'dryRun');
49
+ const revert = (0, vscode_settings_1.isEnablingFlagSet)(parameters.options.revert);
50
+ const explain = (0, vscode_settings_1.isEnablingFlagSet)(parameters.options.explain);
51
+ // This command writes OUTSIDE the project, into the user's global editor
52
+ // settings. A `defaults.noConfirm` in a repo-local `lt.config.json` — which
53
+ // is discovered by walking up from cwd, i.e. can come from a cloned repo —
54
+ // must not be able to silence that prompt. Only an explicit CLI flag does.
55
+ const noConfirm = (0, vscode_settings_1.isEnablingFlagSet)(parameters.options.noConfirm);
56
+ info('');
57
+ info(colors.bold(`lt dev vscode${revert ? ' --revert' : ''}${dryRun ? ' (dry run)' : ''}`));
58
+ info(colors.dim('─'.repeat(64)));
59
+ if (explain) {
60
+ info(colors.bold('\nProfile:'));
61
+ for (const [key, entry] of Object.entries(vscode_settings_1.MEMORY_PROFILE)) {
62
+ info(` ${colors.cyan(key)}`);
63
+ info(` ${colors.dim(entry.reason)}`);
64
+ }
65
+ info(colors.bold('\nDeliberately NOT set:'));
66
+ for (const item of vscode_settings_1.EXCLUDED_FROM_PROFILE) {
67
+ info(` ${colors.yellow(item.key)}`);
68
+ info(` ${colors.dim(item.why)}`);
69
+ }
70
+ info('');
71
+ if (!parameters.options.fromGluegunMenu)
72
+ process.exit();
73
+ return 'dev vscode: explained';
74
+ }
75
+ const all = (0, vscode_settings_1.detectVariants)();
76
+ const { targets, unknownFilter } = (0, vscode_settings_1.selectVariants)(all, parameters.options.variant);
77
+ // A bare `--variant` parses to boolean `true` and matches no id. Reporting
78
+ // that as "no installation found" told users their editor was missing while
79
+ // it was installed — two different problems deserve two different messages.
80
+ if (unknownFilter) {
81
+ info(colors.yellow(` Unknown --variant "${unknownFilter}".`));
82
+ info(colors.dim(` Valid values: ${all.map((v) => v.id).join(' | ')}`));
83
+ if (!parameters.options.fromGluegunMenu)
84
+ process.exit(1);
85
+ return 'dev vscode: unknown variant';
86
+ }
87
+ if (targets.length === 0) {
88
+ info(colors.yellow(' No VS Code installation with a user settings.json found.'));
89
+ info(colors.dim(` Looked for: ${all.map((v) => v.label).join(', ')}`));
90
+ if (!parameters.options.fromGluegunMenu)
91
+ process.exit(1);
92
+ return 'dev vscode: no installation found';
93
+ }
94
+ // Preview first — the user sees the exact before/after per key before
95
+ // anything is written.
96
+ let pending = 0;
97
+ for (const target of targets) {
98
+ const preview = (0, vscode_settings_1.tuneSettingsFile)(target.settingsPath, { dryRun: true, remove: revert });
99
+ info(`\n ${colors.bold(target.label)} ${colors.dim(target.settingsPath)}`);
100
+ if (preview.error) {
101
+ info(` ${colors.red('skipped')} — ${preview.error}`);
102
+ continue;
103
+ }
104
+ for (const change of preview.changes) {
105
+ info(` ${(0, vscode_settings_1.formatChange)(change, colors)}`);
106
+ if (change.action !== 'unchanged')
107
+ pending++;
108
+ }
109
+ }
110
+ if (pending === 0) {
111
+ info(colors.green('\n✓ already up to date — nothing to do\n'));
112
+ if (!parameters.options.fromGluegunMenu)
113
+ process.exit();
114
+ return 'dev vscode: no changes needed';
115
+ }
116
+ if (dryRun) {
117
+ info(colors.dim(`\n${pending} change(s) would be applied. Re-run without --dry-run to apply.\n`));
118
+ if (!parameters.options.fromGluegunMenu)
119
+ process.exit();
120
+ return `dev vscode: dry run, ${pending} pending change(s)`;
121
+ }
122
+ if (!noConfirm && !(yield confirm(`Apply ${pending} change(s)?`, true))) {
123
+ info(colors.dim('\nAborted — nothing written.\n'));
124
+ if (!parameters.options.fromGluegunMenu)
125
+ process.exit();
126
+ return 'dev vscode: aborted';
127
+ }
128
+ let applied = 0;
129
+ for (const target of targets) {
130
+ const result = (0, vscode_settings_1.tuneSettingsFile)(target.settingsPath, { remove: revert });
131
+ if (result.error) {
132
+ info(` ${colors.red('✗')} ${target.label}: ${result.error}`);
133
+ continue;
134
+ }
135
+ if (!result.written)
136
+ continue;
137
+ applied += result.changes.filter((c) => c.action !== 'unchanged').length;
138
+ info(` ${colors.green('✓')} ${target.label} updated ${colors.dim(`(backup: ${result.backupPath})`)}`);
139
+ }
140
+ info(colors.dim('\n Restart VS Code (or run "Developer: Reload Window") for the TS servers to pick this up.\n'));
141
+ if (!parameters.options.fromGluegunMenu)
142
+ process.exit();
143
+ return `dev vscode: applied ${applied} change(s)`;
144
+ }),
145
+ };
146
+ exports.help = {
147
+ aliases: ['vsc'],
148
+ configuration: 'none (writes global editor settings — --noConfirm must be passed explicitly)',
149
+ description: "Apply a verified low-memory profile to VS Code's user settings. Targets the per-workspace TypeScript servers, which dominate memory when many monorepos are open at once.",
150
+ examples: ['dev vscode', 'dev vscode --dry-run', 'dev vscode --explain', 'dev vscode --revert'],
151
+ features: [
152
+ 'JSONC-aware — preserves comments and formatting; refuses to write an unparseable file or a symlink.',
153
+ 'Backs up to settings.json.bak (the first one is kept) and merges object-valued keys, keeping hand-added entries.',
154
+ 'Detects VS Code, Insiders, Cursor and VSCodium; idempotent, with --revert subtracting only its own entries.',
155
+ ],
156
+ name: 'vscode',
157
+ options: [
158
+ { description: 'Show what would change without writing', flag: '--dry-run', type: 'boolean' },
159
+ { description: 'Remove the profile keys again', flag: '--revert', type: 'boolean' },
160
+ {
161
+ description: 'Print the profile with reasons, plus the keys deliberately left out',
162
+ flag: '--explain',
163
+ type: 'boolean',
164
+ },
165
+ {
166
+ description: 'Limit to one installation: code | insiders | cursor | vscodium',
167
+ flag: '--variant',
168
+ type: 'string',
169
+ },
170
+ { description: 'Skip the confirmation prompt', flag: '--noConfirm', type: 'boolean' },
171
+ ],
172
+ };
173
+ module.exports = Object.assign(VsCodeCommand, { help: exports.help });
@@ -14,6 +14,7 @@ const dev_patches_1 = require("../../lib/dev-patches");
14
14
  const framework_detection_1 = require("../../lib/framework-detection");
15
15
  const frontend_framework_detection_1 = require("../../lib/frontend-framework-detection");
16
16
  const heal_check_wrapper_1 = require("../../lib/heal-check-wrapper");
17
+ const heal_vendor_migrate_store_1 = require("../../lib/heal-vendor-migrate-store");
17
18
  const vendor_claude_md_1 = require("../../lib/vendor-claude-md");
18
19
  /**
19
20
  * Update a fullstack workspace — mode-aware.
@@ -208,6 +209,22 @@ const NewCommand = {
208
209
  info('');
209
210
  success(' Added `.lt-dev/` to .gitignore');
210
211
  }
212
+ // ── Self-heal: repair the vendor-mode migration store ──────────────────
213
+ //
214
+ // `migrations-utils/migrate.js` is written ONCE, at conversion time. Projects
215
+ // converted before the template stopped requiring ts-node unconditionally keep
216
+ // the broken file forever — it is project scaffolding, not `src/core/`, so no
217
+ // update path ever revisits it. Those containers die with
218
+ // `Cannot find module 'ts-node'` before applying a single migration, and stay
219
+ // healthy while doing so, because the entrypoint degrades the failure to a
220
+ // warning on purpose. Idempotent, and deliberately blind to stores that guard
221
+ // the require their own way.
222
+ const migrateStoreAsset = (0, path_1.join)(__dirname, '..', '..', 'templates', 'vendor-scripts', 'migrate-store.js');
223
+ const changedStore = (0, heal_vendor_migrate_store_1.healVendorMigrateStore)(apiDir, migrateStoreAsset);
224
+ if (changedStore.length > 0) {
225
+ info('');
226
+ success(` Repaired the vendor migration store: ${changedStore.join(', ')}`);
227
+ }
211
228
  info('');
212
229
  info(colors.bold('For a comprehensive update of everything, use:'));
213
230
  info('');
@@ -9,15 +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
+ exports.buildShardPlaywrightInvocation = buildShardPlaywrightInvocation;
17
+ exports.buildTestAppEnv = buildTestAppEnv;
16
18
  exports.hasTestSession = hasTestSession;
17
19
  exports.resolveTestSession = resolveTestSession;
18
20
  exports.runShardedTestSession = runShardedTestSession;
21
+ exports.shardReportDir = shardReportDir;
19
22
  exports.tearDownAllTestSessions = tearDownAllTestSessions;
20
23
  exports.tearDownTestSession = tearDownTestSession;
24
+ exports.testAppEntryCandidates = testAppEntryCandidates;
21
25
  /**
22
26
  * Ephemeral, isolated test session for `lt dev test`.
23
27
  *
@@ -101,6 +105,47 @@ exports.TEST_INITIAL_ADMIN_ENV = {
101
105
  NSC__SYSTEM_SETUP__INITIAL_ADMIN__NAME: 'CI Admin',
102
106
  NSC__SYSTEM_SETUP__INITIAL_ADMIN__PASSWORD: 'CiThrowawayAdmin123!',
103
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';
104
149
  /**
105
150
  * Heuristic for the default local shard count (`--shard auto` / bare `--shard`).
106
151
  *
@@ -234,6 +279,11 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
234
279
  dbName,
235
280
  identity: testIdentity,
236
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);
237
287
  const pids = {};
238
288
  // --- API: compiled (`node dist`) for stability; fall back to the project's
239
289
  // own dev start script. `skipBuild` (sibling shards) reuses the dist the
@@ -279,25 +329,41 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
279
329
  // buildDevEnv sets NUXT_PUBLIC_API_PROXY=false, so the built app talks
280
330
  // cross-origin to the test API exactly like prod (the injected session cookie
281
331
  // must be a cross-subdomain DOMAIN cookie — see the project's parseCookieHeader).
282
- // 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. ---
283
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-*');
284
350
  const appPm = (0, dev_package_manager_1.pickPackageManager)(layout.appDir);
285
351
  let appBuild = 0;
286
352
  if (!skipBuild) {
287
353
  log.info(log.dim('Building App (nuxt build, for speed + prod-fidelity) …'));
288
354
  appBuild = yield (0, dev_process_1.runChildInherit)(appPm.bin, appPm.runScript('build'), {
289
355
  cwd: layout.appDir,
290
- env: devEnv.app.env,
356
+ env: appEnv,
291
357
  });
292
358
  }
293
- const appEntry = ['.output/server/index.mjs']
359
+ const appEntry = testAppEntryCandidates()
294
360
  .map((rel) => (0, path_1.join)(layout.appDir, rel))
295
361
  .find((p) => (0, fs_1.existsSync)(p));
296
362
  let appSpawn;
297
363
  if (appBuild === 0 && appEntry) {
298
364
  appSpawn = (0, dev_process_1.spawnDetached)('node', [appEntry], {
299
365
  cwd: layout.appDir,
300
- env: devEnv.app.env,
366
+ env: appEnv,
301
367
  logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
302
368
  });
303
369
  }
@@ -305,7 +371,7 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
305
371
  log.warn(`built app not available — falling back to \`${appPm.bin} dev\` (slower: cold-compiles routes).`);
306
372
  appSpawn = (0, dev_process_1.spawnDetached)(appPm.bin, appPm.runScript('dev'), {
307
373
  cwd: layout.appDir,
308
- env: devEnv.app.env,
374
+ env: appEnv,
309
375
  logFile: (0, path_1.join)(layout.root, '.lt-dev', names.appLog),
310
376
  });
311
377
  }
@@ -338,9 +404,62 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
338
404
  // Expose THIS stack's API log path so the caller can point the auth E2E specs
339
405
  // (via NEST_SERVER_LOG) at the exact isolated log — correct per shard.
340
406
  const apiLogPath = layout.apiDir ? (0, path_1.join)(layout.root, '.lt-dev', names.apiLog) : undefined;
341
- return { apiLogPath, apiUrl, appEnv: devEnv.app.env, appUrl, dbName, pids, testIdentity };
407
+ return { apiLogPath, apiUrl, appEnv, appUrl, dbName, pids, testIdentity };
342
408
  });
343
409
  }
410
+ /**
411
+ * Build the Playwright CLI argv + per-shard env overrides for ONE shard of a
412
+ * `lt dev test --shard` run.
413
+ *
414
+ * DEV-2676 — reporter handling: this path must NOT pass `--reporter`. A CLI
415
+ * `--reporter` REPLACES the project's whole `reporter` list from
416
+ * playwright.config.ts (Playwright resolves CLI-over-config, it never appends —
417
+ * `Runner._parseConfig` in playwright's `lib/runner/index.js` does
418
+ * `result.reporter = [...configOverrides.reporter]`), silently dropping any
419
+ * release gate a project wires in AS a reporter. SVL's DEV-2098 gate (`./tests/no-skips.reporter.ts`)
420
+ * turns a run RED when a spec was skipped; the old `--reporter=line` here
421
+ * clobbered it, so a skipped test passed with exit 0 under `--shard` — the exact
422
+ * hole this closes. Omitting `--reporter` lets the configured reporters run (the
423
+ * gate included); Playwright auto-prepends a compact `line` (local) / `dot` (CI)
424
+ * reporter when none of them claim stdio, so the captured per-shard log stays
425
+ * readable without us overriding anything.
426
+ *
427
+ * The HTML reporter is the only common config reporter that is shard-hostile:
428
+ * every shard shares one project dir and would write the same
429
+ * `playwright-report/`, racing each other's report files. We hand each shard its
430
+ * own HTML output dir and force `open: never` (defensive — the non-TTY,
431
+ * file-captured child never auto-opens a browser anyway). Both env vars are
432
+ * inert for a project without an HTML reporter, so the fix stays generic.
433
+ *
434
+ * Playwright is invoked via the manager's `exec` (NOT `<pm> run test:e2e -- …`):
435
+ * forwarding option flags through `<pm> run`'s `--` is unreliable — pnpm passed
436
+ * the separator on to Playwright, which then read `--shard` as a file FILTER, so
437
+ * every shard ran the whole suite. `exec` hands args straight to the binary
438
+ * (mirrors CI); the helper inserts `--` for npm so those flags survive.
439
+ */
440
+ function buildShardPlaywrightInvocation(pm, shardIndex, total, forwarded, htmlReportDir) {
441
+ return {
442
+ args: pm.exec('playwright', ['test', `--shard=${shardIndex}/${total}`, ...forwarded]),
443
+ env: {
444
+ PLAYWRIGHT_HTML_OPEN: 'never',
445
+ PLAYWRIGHT_HTML_OUTPUT_DIR: htmlReportDir,
446
+ },
447
+ };
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
+ }
344
463
  /** True when a test session file exists (used by status/down). */
345
464
  function hasTestSession(root) {
346
465
  return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
@@ -396,21 +515,14 @@ function runShardedTestSession(layout, baseIdentity, log, opts) {
396
515
  // suite runs under concurrent sharded load, so it can relax navigation /
397
516
  // test timeouts (N built SSR servers + N Chromium saturate the CPU and slow
398
517
  // every navigation) without loosening them for serial runs.
399
- const env = Object.assign(Object.assign(Object.assign({}, ctx.appEnv), { LT_DEV_TEST_SHARDS: String(total), MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` }), (ctx.apiLogPath ? { NEST_SERVER_LOG: ctx.apiLogPath } : {}));
518
+ // Reporter + shard args come from the shared helper, which deliberately
519
+ // does NOT inject `--reporter` so the project's own release gate (e.g.
520
+ // SVL's DEV-2098 no-skips reporter) still runs under `--shard` (DEV-2676),
521
+ // and isolates the HTML report per shard.
522
+ const reportDir = shardReportDir(layout.root, index);
523
+ const { args, env: reporterEnv } = buildShardPlaywrightInvocation(opts.pm, index, total, opts.forwarded, reportDir);
524
+ const env = Object.assign(Object.assign(Object.assign(Object.assign({}, ctx.appEnv), { LT_DEV_TEST_SHARDS: String(total), MONGO_URI: `mongodb://127.0.0.1/${ctx.dbName}` }), (ctx.apiLogPath ? { NEST_SERVER_LOG: ctx.apiLogPath } : {})), reporterEnv);
400
525
  const logFile = (0, path_1.join)(layout.root, '.lt-dev', `shard.${index}.test.log`);
401
- // Invoke Playwright DIRECTLY via the manager's `exec` (NOT `<pm> run
402
- // test:e2e -- …`): forwarding option flags through `<pm> run`'s `--`
403
- // is unreliable — pnpm passed the separator on to Playwright, which
404
- // then read `--shard` / `--reporter` as file FILTERS (not options) →
405
- // every shard ran the whole suite. `<pm> exec` hands args straight
406
- // to the binary (mirrors CI); the helper inserts `--` for npm so
407
- // those flags don't get re-parsed as npm's own.
408
- const args = opts.pm.exec('playwright', [
409
- 'test',
410
- `--shard=${index}/${total}`,
411
- '--reporter=line',
412
- ...opts.forwarded,
413
- ]);
414
526
  const code = yield (0, dev_process_1.runChildToFile)(opts.pm.bin, args, { cwd: appDir, env, logFile });
415
527
  return { code, index, logFile };
416
528
  })));
@@ -426,6 +538,16 @@ function runShardedTestSession(layout, baseIdentity, log, opts) {
426
538
  return failed === 0 ? 0 : 1;
427
539
  });
428
540
  }
541
+ /**
542
+ * The per-shard HTML report directory. Distinct per shard (the `<index>` in the
543
+ * path) so N shards never race on a shared `playwright-report/` — this is the
544
+ * actual isolation `buildShardPlaywrightInvocation`'s `PLAYWRIGHT_HTML_OUTPUT_DIR`
545
+ * relies on. Extracted as a pure helper so the shard-distinctness guarantee is
546
+ * unit-testable without booting the (deliberately untested) real orchestrator.
547
+ */
548
+ function shardReportDir(root, shardIndex) {
549
+ return (0, path_1.join)(root, '.lt-dev', `shard.${shardIndex}.playwright-report`);
550
+ }
429
551
  /**
430
552
  * Tear down the unsharded test stack AND every sharded stack discovered on disk
431
553
  * (`state.test.<i>.json` in `.lt-dev/`). Used by `lt dev test down` so a
@@ -497,11 +619,45 @@ function tearDownTestSession(layout_1, baseIdentity_1, log_1) {
497
619
  delete reg.projects[testIdentity.slug];
498
620
  (0, dev_state_1.saveRegistry)(reg);
499
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
+ }
500
642
  if (!opts.silent && stopped.length > 0)
501
643
  log.info(`Stopped test stack: ${stopped.join(', ')}`);
502
644
  return { stopped };
503
645
  });
504
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
+ }
505
661
  /**
506
662
  * Resolve the per-stack file/identity names. For a sharded run (`shardIndex`
507
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
  /**