@lenne.tech/cli 1.46.0 → 1.48.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.
@@ -16,12 +16,14 @@ exports.bringUpTestSession = bringUpTestSession;
16
16
  exports.buildShardPlaywrightInvocation = buildShardPlaywrightInvocation;
17
17
  exports.buildTestAppEnv = buildTestAppEnv;
18
18
  exports.hasTestSession = hasTestSession;
19
+ exports.isStackServing = isStackServing;
19
20
  exports.resolveTestSession = resolveTestSession;
20
21
  exports.runShardedTestSession = runShardedTestSession;
21
22
  exports.shardReportDir = shardReportDir;
22
23
  exports.tearDownAllTestSessions = tearDownAllTestSessions;
23
24
  exports.tearDownTestSession = tearDownTestSession;
24
25
  exports.testAppEntryCandidates = testAppEntryCandidates;
26
+ exports.unreachableStackError = unreachableStackError;
25
27
  /**
26
28
  * Ephemeral, isolated test session for `lt dev test`.
27
29
  *
@@ -35,12 +37,16 @@ exports.testAppEntryCandidates = testAppEntryCandidates;
35
37
  * - dev session : svl.localhost / api.svl.localhost → db `<…>-local`
36
38
  * - test session : svl-test.localhost / api.svl-test.… → db `<…>-test`
37
39
  *
38
- * Both halves run BUILT for speed + prod-fidelity: the API COMPILED (`node dist`,
39
- * ts-node intermittently dies mid-run) and the App as the production Nitro output
40
- * (`nuxt build` → `node .output/server/index.mjs`, no Vite cold-compile). Each
41
- * falls back to its dev runner (`pnpm start` / `pnpm dev`) when no build output is
42
- * found. bringUp waits for a real 2xx on the API `/meta` before returning so the
43
- * suite never starts against a not-yet-serving API.
40
+ * Both halves run BUILT for speed + prod-fidelity: the API COMPILED (from `dist`,
41
+ * because ts-node intermittently dies mid-run) and the App as the production Nitro
42
+ * output (`nuxt build` → `node .output/server/index.mjs`, no Vite cold-compile).
43
+ * Each falls back to its dev runner (`pnpm start` / `pnpm dev`) when no build
44
+ * output is found. The API's runtime follows the project (`resolveApiRuntime`):
45
+ * a Bun-built bundle is started with `bun`, everything else with `node`.
46
+ *
47
+ * bringUp waits for BOTH halves to answer and THROWS when one never does, so the
48
+ * suite can never run against a dead stack (DEV-3208) — see `isStackServing` for
49
+ * what counts as answering.
44
50
  *
45
51
  * Lifecycle: `bringUpTestSession` → run Playwright → `tearDownTestSession`.
46
52
  * Teardown is idempotent and residue-free (processes, Caddy block, env bridge,
@@ -308,7 +314,10 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
308
314
  const apiEnv = Object.assign(Object.assign(Object.assign({}, exports.TEST_INITIAL_ADMIN_ENV), devEnv.api.env), { NODE_ENV: 'local' });
309
315
  let apiSpawn;
310
316
  if (build === 0 && entry) {
311
- apiSpawn = (0, dev_process_1.spawnDetached)('node', [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], {
312
321
  cwd: layout.apiDir,
313
322
  env: apiEnv,
314
323
  logFile: (0, path_1.join)(layout.root, '.lt-dev', names.apiLog),
@@ -387,26 +396,43 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
387
396
  (0, dev_state_1.saveSession)(layout.root, { pids, startedAt: new Date().toISOString() }, names.sessionFile);
388
397
  // ENV bridge for external tooling (kept separate from the dev `.env`).
389
398
  (0, dev_env_bridge_1.writeEnvBridge)(layout.root, devEnv, dbName, names.bridgeFile);
390
- // Wait for the test App to answer (best-effort).
399
+ // THIS stack's log paths. Resolved BEFORE the readiness waits below, because
400
+ // the abort messages point at them: the log is the only place a crash-on-boot
401
+ // is recorded, and an abort that withholds it just relocates the guesswork.
402
+ // `apiLogPath` is also handed to the caller so the auth E2E specs (via
403
+ // NEST_SERVER_LOG) read the exact isolated log — correct per shard.
404
+ const apiLogPath = layout.apiDir ? (0, path_1.join)(layout.root, '.lt-dev', names.apiLog) : undefined;
405
+ const appLogPath = layout.appDir ? (0, path_1.join)(layout.root, '.lt-dev', names.appLog) : undefined;
406
+ // Both waits below ABORT rather than warn (DEV-3208). A warning let Playwright
407
+ // run the whole suite against a dead stack: every data-dependent spec passed
408
+ // trivially or skipped, so a run that verified NOTHING was indistinguishable
409
+ // from one that verified everything — worse than a red run, because nobody
410
+ // goes looking. `isStackServing` decides what counts as up, and the liveness
411
+ // guard turns a crash-on-boot into an abort in the same 300ms it took to
412
+ // happen, instead of after the full timeout.
413
+ //
414
+ // `gone` is deliberately true for an ABSENT pid too: reaching here means the
415
+ // spawn was attempted, so no pid means it failed outright and there is nothing
416
+ // left to wait for. Being wrong here costs nothing — the timeout still bounds
417
+ // the wait, so this only ever makes the abort arrive sooner.
418
+ const gone = (pid) => pid === undefined || !(0, dev_state_1.isPidAlive)(pid);
419
+ // Wait for the test App to answer.
391
420
  if (appUrl) {
392
421
  log.info(log.dim(`Waiting for ${appUrl} …`));
393
- yield (0, dev_process_1.waitForHttp)(appUrl, 90000);
422
+ const appReady = yield (0, dev_process_1.waitForHttp)(appUrl, 90000, isStackServing, () => gone(pids.app));
423
+ if (!appReady)
424
+ throw unreachableStackError('App', appUrl, appLogPath);
394
425
  }
395
- // Wait for the test API to actually SERVE (real 2xx on /meta) before handing
396
- // off to Playwright. Previously bringUp only waited for the App, so a compiled
397
- // API still connecting to Mongo made the first specs skip via the suite's
398
- // `ensureApiReachableOrSkip` guard (the API-readiness race). A strict 2xx is
399
- // required: Caddy answers 502 while its upstream is still booting, which the
400
- // default (lenient) predicate would accept as "up".
426
+ // Wait for the test API to actually SERVE before handing off to Playwright.
427
+ // Previously bringUp only waited for the App, so a compiled API still
428
+ // connecting to its database made the first specs skip via the suite's
429
+ // `ensureApiReachableOrSkip` guard (the API-readiness race).
401
430
  if (apiUrl) {
402
431
  log.info(log.dim(`Waiting for ${apiUrl}/meta …`));
403
- const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, (status) => status >= 200 && status < 300);
432
+ const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, isStackServing, () => gone(pids.api));
404
433
  if (!apiReady)
405
- log.warn(`Test API did not answer 2xx on ${apiUrl}/meta within 120s — the first specs may skip.`);
434
+ throw unreachableStackError('API', `${apiUrl}/meta`, apiLogPath);
406
435
  }
407
- // Expose THIS stack's API log path so the caller can point the auth E2E specs
408
- // (via NEST_SERVER_LOG) at the exact isolated log — correct per shard.
409
- const apiLogPath = layout.apiDir ? (0, path_1.join)(layout.root, '.lt-dev', names.apiLog) : undefined;
410
436
  return { apiLogPath, apiUrl, appEnv, appUrl, dbName, pids, testIdentity };
411
437
  });
412
438
  }
@@ -467,6 +493,28 @@ function buildTestAppEnv(appEnv) {
467
493
  function hasTestSession(root) {
468
494
  return (0, dev_state_1.loadSession)(root, dev_state_1.TEST_SESSION_FILE) !== null;
469
495
  }
496
+ /**
497
+ * Whether an HTTP status proves the component ITSELF answered.
498
+ *
499
+ * Readiness here means liveness, not health: the question is "did my process get
500
+ * far enough to serve HTTP", not "did it like this particular request". So a 404
501
+ * and a 500 both count as ready — they came FROM the upstream — while Caddy's
502
+ * gateway errors do not, because Caddy returns those for the entire window in
503
+ * which its upstream is not yet listening.
504
+ *
505
+ * The 404 case is the load-bearing one. The API probe targets `/meta`, an
506
+ * endpoint `@lenne.tech/nest-server` provides and `nest-base` does not: a healthy
507
+ * nest-base API answers 404 there (verified against a live one). The previous
508
+ * strict-2xx predicate was harmless while a failed wait only logged a warning,
509
+ * but this module now ABORTS on it — under strict 2xx that would block
510
+ * `lt dev test` for every nest-base project on a perfectly healthy API, which is
511
+ * worse than the false-green it replaces. Probing a list of per-framework paths
512
+ * would work too, but it needs updating for every new template; "something other
513
+ * than the proxy answered" needs no such list.
514
+ */
515
+ function isStackServing(status) {
516
+ return status > 0 && ![502, 503, 504].includes(status);
517
+ }
470
518
  /** Build the dedicated test identity + test DB name for a project. */
471
519
  function resolveTestSession(layout, baseIdentity, shardIndex, devDbName) {
472
520
  const names = testStackNames(shardIndex);
@@ -661,6 +709,23 @@ function tearDownTestSession(layout_1, baseIdentity_1, log_1) {
661
709
  function testAppEntryCandidates() {
662
710
  return [`${exports.TEST_NITRO_OUTPUT_DIR}/server/index.mjs`, '.output/server/index.mjs'];
663
711
  }
712
+ /**
713
+ * The abort thrown when a test-stack component never becomes reachable.
714
+ *
715
+ * Phrased as an abort, not a diagnosis, because the cause lives in the component's
716
+ * own log and nowhere else — the process is detached, so its stderr never reaches
717
+ * this terminal. Naming the log file is therefore the whole value of the message:
718
+ * an abort that only says "did not come up" moves the guesswork one step later
719
+ * instead of ending it.
720
+ *
721
+ * `lt dev test` catches this, reports it, tears the stack down and exits non-zero,
722
+ * so Playwright never starts (DEV-3208).
723
+ */
724
+ function unreachableStackError(component, url, logFile) {
725
+ const where = logFile ? ` Why it never came up: ${logFile}` : '';
726
+ return new Error(`Test ${component} never became reachable at ${url} — aborting before Playwright runs, ` +
727
+ `because a suite run against a dead ${component} passes trivially and proves nothing.${where}`);
728
+ }
664
729
  /**
665
730
  * Resolve the per-stack file/identity names. For a sharded run (`shardIndex`
666
731
  * given) everything gets a `.<i>` / `-<i>` suffix so N stacks coexist without
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nonInteractiveGitEnv = nonInteractiveGitEnv;
4
+ /**
5
+ * The environment git subprocesses run with so they never block on a prompt.
6
+ *
7
+ * Two variables, one rule each:
8
+ *
9
+ * `GIT_TERMINAL_PROMPT=0` stops git asking for credentials on a dead terminal.
10
+ * A prompt in a detached or CI child is not a question anyone can answer — it is
11
+ * a hang that looks like a slow network.
12
+ *
13
+ * `GIT_SSH_COMMAND` only supplies a DEFAULT, and the distinction is expensive.
14
+ * `BatchMode=yes` makes ssh fail rather than PROMPT — but an agent that stalls is
15
+ * not a prompt. On a 1Password-backed machine the agent is reachable and every
16
+ * signature needs interactive approval; unattended it waits, then reports
17
+ * `communication with agent failed`. Measured at 61s per fetch, which made
18
+ * `lt git update --dry-run` take 62s and `lt git create --dry-run` 123s (two
19
+ * fetches). `ConnectTimeout` does not bound that — it covers the TCP connect,
20
+ * not the agent.
21
+ *
22
+ * Waiting for a human to approve a key is legitimate for an interactive command,
23
+ * so the default itself is unchanged. What was wrong was assigning it
24
+ * UNCONDITIONALLY: that overrode anyone who had configured ssh deliberately,
25
+ * including a test harness trying to make the behaviour deterministic. A
26
+ * caller's own value therefore always wins (`IdentityAgent=none` in their env
27
+ * drops the same fetch to ~1s with a clean `Permission denied (publickey)`); the
28
+ * fallback only keeps an unconfigured machine from hanging.
29
+ *
30
+ * This exists as an environment object rather than a shell prefix because
31
+ * `VAR=value git …` is not assignment syntax to cmd.exe — it reads the whole
32
+ * thing as a command name and fails with "'GIT_TERMINAL_PROMPT' is not
33
+ * recognized". Handing the child an env works on every platform, and it is the
34
+ * only spelling that does. Keep it here rather than inline at each call site:
35
+ * this is the single place the default is defined, which is what lets a test
36
+ * assert that nobody assigns GIT_SSH_COMMAND unconditionally anywhere else.
37
+ *
38
+ * One caveat if you ever read a variable back off the returned object: spreading
39
+ * `process.env` produces a PLAIN object, which on Windows loses the
40
+ * case-insensitive lookup the real `process.env` proxy provides. The value is
41
+ * intact under its original spelling (`Path`, not `PATH`), and handing the whole
42
+ * object to `spawn` works — but `result.PATH` is undefined there.
43
+ */
44
+ function nonInteractiveGitEnv(base = process.env) {
45
+ return Object.assign(Object.assign({}, base), { GIT_SSH_COMMAND: base.GIT_SSH_COMMAND || 'ssh -o ConnectTimeout=5 -o BatchMode=yes', GIT_TERMINAL_PROMPT: '0' });
46
+ }
@@ -1,6 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.compareVersions = compareVersions;
4
+ exports.compareWrapperVersions = compareWrapperVersions;
3
5
  exports.healCheckWrapper = healCheckWrapper;
6
+ exports.keptWrapperReason = keptWrapperReason;
7
+ exports.readWrapperVersion = readWrapperVersion;
4
8
  exports.resolveCopySet = resolveCopySet;
5
9
  const child_process_1 = require("child_process");
6
10
  const fs_1 = require("fs");
@@ -9,6 +13,63 @@ const path_1 = require("path");
9
13
  const WRAPPER = 'node scripts/check.mjs';
10
14
  /** Where the wrapper and its imports live inside a project. */
11
15
  const SCRIPTS_DIR = 'scripts';
16
+ /**
17
+ * `// @lt-check-wrapper 3.12.0` on line 2 of the wrapper: the lt-monorepo RELEASE it belongs
18
+ * to (never a project's own package version). Same expression as lt-monorepo's
19
+ * `scripts/check-wrapper-version.cjs#MARKER_RE`, so both sides agree on the format: a whole
20
+ * line, so prose mentioning the tag never matches, and `\r?` for a CRLF checkout.
21
+ */
22
+ const VERSION_MARKER = /^\/\/ @lt-check-wrapper (\S+)\r?$/m;
23
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
24
+ /**
25
+ * Compare two `x.y.z[-pre]` versions: negative when `a < b`, 0 when equal,
26
+ * positive when `a > b`. A prerelease sorts before its release.
27
+ */
28
+ function compareVersions(a, b) {
29
+ const parse = (v) => {
30
+ const [core, pre = ''] = v.split('-', 2);
31
+ return { core: core.split('.').map(Number), pre };
32
+ };
33
+ const pa = parse(a);
34
+ const pb = parse(b);
35
+ for (let i = 0; i < 3; i++) {
36
+ const diff = (pa.core[i] || 0) - (pb.core[i] || 0);
37
+ if (diff !== 0) {
38
+ return diff;
39
+ }
40
+ }
41
+ if (pa.pre === pb.pre) {
42
+ return 0;
43
+ }
44
+ if (!pa.pre) {
45
+ return 1;
46
+ }
47
+ if (!pb.pre) {
48
+ return -1;
49
+ }
50
+ return pa.pre < pb.pre ? -1 : 1;
51
+ }
52
+ /** Read both wrapper markers and classify how the project relates to the bundle. */
53
+ function compareWrapperVersions(projectRoot, assetPath) {
54
+ const bundled = readWrapperVersion(assetPath);
55
+ const project = readWrapperVersion((0, path_1.join)(projectRoot, SCRIPTS_DIR, 'check.mjs'));
56
+ let relation;
57
+ if (project === null) {
58
+ relation = 'legacy';
59
+ }
60
+ else if (!SEMVER.test(project)) {
61
+ relation = 'unrecognised';
62
+ }
63
+ else if (bundled === null || !SEMVER.test(bundled)) {
64
+ // A marked project against an unmarked bundle: the bundle predates markers.
65
+ relation = 'project-newer';
66
+ }
67
+ else {
68
+ const order = compareVersions(bundled, project);
69
+ relation = order > 0 ? 'bundled-newer' : order < 0 ? 'project-newer' : 'same-release';
70
+ }
71
+ return { bundled, project, relation };
72
+ }
12
73
  /**
13
74
  * Idempotently install the report-driven check wrapper — and every module it
14
75
  * imports — into a project.
@@ -68,7 +129,13 @@ function healCheckWrapper(projectRoot, assetPath) {
68
129
  // the doc block above.
69
130
  const plans = copies.map((copy) => planCopy(projectRoot, copy));
70
131
  const blocked = plans.filter((p) => p.action === 'skip');
71
- if (blocked.length > 0) {
132
+ // Never downgrade: a project created from a newer lt-monorepo than this CLI
133
+ // bundles keeps its wrapper. Replacing it silently dropped fixes before.
134
+ const kept = keptWrapperReason(projectRoot, assetPath);
135
+ if (kept) {
136
+ changed.push(`${copies.map((c) => c.rel).join(' + ')} (skipped: ${kept})`);
137
+ }
138
+ else if (blocked.length > 0) {
72
139
  // One entry for the whole set: the set is what could not be updated, and
73
140
  // naming only the blocking member would suggest the others did land.
74
141
  const names = blocked.map((p) => p.rel).join(', ');
@@ -94,6 +161,39 @@ function healCheckWrapper(projectRoot, assetPath) {
94
161
  }
95
162
  return changed;
96
163
  }
164
+ /**
165
+ * Why the project's wrapper must be kept as it is, or null when heal may replace it.
166
+ * Shared by heal (skip reason) and doctor (INFO line), so both say the same thing.
167
+ */
168
+ function keptWrapperReason(projectRoot, assetPath) {
169
+ const { bundled, project, relation } = compareWrapperVersions(projectRoot, assetPath);
170
+ switch (relation) {
171
+ case 'project-newer':
172
+ return `project wrapper ${project} is newer than this CLI's ${bundled !== null && bundled !== void 0 ? bundled : '(unversioned)'} — update lt`;
173
+ case 'same-release': {
174
+ // Identical content is simply up to date; a missing sibling may still be installed.
175
+ const projectCheck = (0, path_1.join)(projectRoot, SCRIPTS_DIR, 'check.mjs');
176
+ if ((0, fs_1.readFileSync)(projectCheck, 'utf8') === (0, fs_1.readFileSync)(assetPath, 'utf8')) {
177
+ return null;
178
+ }
179
+ return `project wrapper differs from this CLI's copy of the same release ${project} — kept, because the release number cannot tell which one is newer; update lt after the next lt-monorepo release`;
180
+ }
181
+ case 'unrecognised':
182
+ return `project wrapper carries an unrecognised @lt-check-wrapper marker "${project}" — kept`;
183
+ default:
184
+ return null;
185
+ }
186
+ }
187
+ /** The `@lt-check-wrapper` version of a wrapper, or null when the file is missing or unmarked. */
188
+ function readWrapperVersion(file) {
189
+ var _a, _b;
190
+ try {
191
+ return (_b = (_a = VERSION_MARKER.exec((0, fs_1.readFileSync)(file, 'utf8'))) === null || _a === void 0 ? void 0 : _a[1]) !== null && _b !== void 0 ? _b : null;
192
+ }
193
+ catch (_c) {
194
+ return null;
195
+ }
196
+ }
97
197
  /**
98
198
  * The wrapper plus the transitive closure of its relative imports.
99
199
  *
@@ -103,11 +203,6 @@ function healCheckWrapper(projectRoot, assetPath) {
103
203
  */
104
204
  function resolveCopySet(assetPath) {
105
205
  const assetDir = (0, path_1.dirname)(assetPath);
106
- // Regular files only — a directory named `*.mjs` would otherwise reach
107
- // copyFileSync and abort the whole migration with EISDIR.
108
- const available = new Set((0, fs_1.readdirSync)(assetDir, { withFileTypes: true })
109
- .filter((entry) => entry.isFile())
110
- .map((entry) => entry.name));
111
206
  const copies = [{ rel: `${SCRIPTS_DIR}/check.mjs`, source: assetPath }];
112
207
  // Keyed by target rel, NOT by source basename: the asset lands as
113
208
  // `scripts/check.mjs` whatever it is called, so a `check.mjs` sitting beside a
@@ -128,14 +223,23 @@ function resolveCopySet(assetPath) {
128
223
  catch (_a) {
129
224
  continue;
130
225
  }
131
- for (const match of source.matchAll(/\bfrom\s+['"]\.\/([^'"/]+)['"]/g)) {
132
- const name = match[1];
133
- const rel = `${SCRIPTS_DIR}/${name}`;
134
- if (claimed.has(rel) || !available.has(name)) {
226
+ // Resolved against the IMPORTING file, so `./lib/audit-report.mjs` in the
227
+ // wrapper and `./ansi.mjs` inside `lib/` both land where Node looks for them.
228
+ // Subdirectories count: copying only the wrapper's direct siblings installed
229
+ // a `check.mjs` whose `./lib/*` imports resolved to nothing.
230
+ for (const match of source.matchAll(/\bfrom\s+['"](\.\/[^'"]+)['"]/g)) {
231
+ const resolved = (0, path_1.resolve)((0, path_1.dirname)(file), match[1]);
232
+ const inAsset = (0, path_1.relative)(assetDir, resolved);
233
+ // Never out of the asset dir: an import that climbs out (`./../x.mjs`)
234
+ // must not claim a path in the project's scripts/.
235
+ if (!inAsset || inAsset === '..' || inAsset.startsWith(`..${path_1.sep}`) || (0, path_1.isAbsolute)(inAsset)) {
236
+ continue;
237
+ }
238
+ const rel = `${SCRIPTS_DIR}/${inAsset.split(path_1.sep).join('/')}`;
239
+ if (claimed.has(rel) || !isRegularFile(resolved)) {
135
240
  continue;
136
241
  }
137
242
  claimed.add(rel);
138
- const resolved = (0, path_1.join)(assetDir, name);
139
243
  copies.push({ rel, source: resolved });
140
244
  queue.push(resolved);
141
245
  }
@@ -159,6 +263,19 @@ function hasUncommittedChanges(projectRoot, relPath) {
159
263
  return false;
160
264
  }
161
265
  }
266
+ /**
267
+ * True for a regular file, checked without following a symlink. A directory
268
+ * named `*.mjs` would otherwise reach copyFileSync and abort the whole migration
269
+ * with EISDIR.
270
+ */
271
+ function isRegularFile(target) {
272
+ try {
273
+ return (0, fs_1.lstatSync)(target).isFile();
274
+ }
275
+ catch (_a) {
276
+ return false;
277
+ }
278
+ }
162
279
  /** True when `target` is a symlink (checked without following it). */
163
280
  function isSymlink(target) {
164
281
  try {
@@ -0,0 +1,232 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findDangerousFixFlagUsage = findDangerousFixFlagUsage;
4
+ exports.healDangerousOxlintFixFlags = healDangerousOxlintFixFlags;
5
+ exports.healOxlintrcFilename = healOxlintrcFilename;
6
+ const child_process_1 = require("child_process");
7
+ const fs_1 = require("fs");
8
+ const path_1 = require("path");
9
+ const LEGACY = 'oxlint.json';
10
+ const TARGET = '.oxlintrc.json';
11
+ /** A `-c`/`--config` flag pointing at the legacy file, e.g. `-c oxlint.json`, `--config=./oxlint.json`. */
12
+ const CONFIG_FLAG = /(-c|--config)(\s+|=)(\.\/)?oxlint\.json(?![\w.-])/g;
13
+ /**
14
+ * oxlint flags that apply behaviour-changing fixes: `--fix-suggestions` (e.g. deletes
15
+ * console calls for no-console) and `--fix-dangerously`. Matches a USE of the flag —
16
+ * preceded by whitespace or a quote — not a mention in backticks such as the comment
17
+ * "Never `--fix-suggestions`" the current templates carry.
18
+ */
19
+ const DANGEROUS_FIX_FLAG_USE = /(?:^|[\s'"])--fix-(?:suggestions|dangerously)(?![\w-])/m;
20
+ /** The removable form: the flag as a further argument after whitespace. `--fix` itself stays. */
21
+ const DANGEROUS_FIX_FLAG_ARG = /[ \t]+--fix-(?:suggestions|dangerously)(?![\w-])/g;
22
+ const LINT_STAGED_FILES = [
23
+ '.lintstagedrc',
24
+ '.lintstagedrc.json',
25
+ '.lintstagedrc.yaml',
26
+ '.lintstagedrc.yml',
27
+ '.lintstagedrc.js',
28
+ '.lintstagedrc.cjs',
29
+ '.lintstagedrc.mjs',
30
+ 'lint-staged.config.js',
31
+ 'lint-staged.config.cjs',
32
+ 'lint-staged.config.mjs',
33
+ ];
34
+ /**
35
+ * Files in which a dangerous fix flag is still USED. The root `scripts/check.mjs` counts
36
+ * for the gate but is not stripped here — `healCheckWrapper` owns that file.
37
+ * Paths are relative to `workspaceRoot` (or `appDir` for a standalone app).
38
+ */
39
+ function findDangerousFixFlagUsage(appDir, workspaceRoot) {
40
+ const base = workspaceRoot !== null && workspaceRoot !== void 0 ? workspaceRoot : appDir;
41
+ const files = [
42
+ ...(workspaceRoot ? [(0, path_1.join)(workspaceRoot, 'scripts', 'check.mjs')] : []),
43
+ ...fixFlagFiles(appDir, workspaceRoot),
44
+ ];
45
+ return [...new Set(files)].filter((file) => usesDangerousFixFlag(file)).map((file) => repoRelative(base, file));
46
+ }
47
+ /**
48
+ * Remove `--fix-suggestions` and `--fix-dangerously` from the project's lint commands
49
+ * — root and app `package.json` (scripts and inline lint-staged), lint-staged configs
50
+ * and the app's own `scripts/check.mjs` — so the oxlint config can be loaded safely.
51
+ *
52
+ * Textual, so formatting stays exactly as it was. Only tracked files without
53
+ * uncommitted changes are edited (git can restore them); an untracked, dirty or
54
+ * symlinked file is reported in `skipped` and keeps blocking the rename.
55
+ */
56
+ function healDangerousOxlintFixFlags(appDir, workspaceRoot) {
57
+ const base = workspaceRoot !== null && workspaceRoot !== void 0 ? workspaceRoot : appDir;
58
+ const result = { changed: [], skipped: [] };
59
+ for (const file of fixFlagFiles(appDir, workspaceRoot)) {
60
+ if (!usesDangerousFixFlag(file)) {
61
+ continue;
62
+ }
63
+ const rel = repoRelative(base, file);
64
+ const dir = (0, path_1.join)(file, '..');
65
+ const name = (0, path_1.relative)(dir, file);
66
+ if (isSymlink(file)) {
67
+ result.skipped.push(`${rel} (symlink)`);
68
+ }
69
+ else if (!isTracked(dir, name)) {
70
+ result.skipped.push(`${rel} (not tracked by git — remove the flag by hand)`);
71
+ }
72
+ else if (hasUncommittedChanges(dir, name)) {
73
+ result.skipped.push(`${rel} (uncommitted changes — commit or discard them, then re-run)`);
74
+ }
75
+ else {
76
+ const content = (0, fs_1.readFileSync)(file, 'utf8');
77
+ const stripped = content.replace(DANGEROUS_FIX_FLAG_ARG, '');
78
+ if (stripped !== content) {
79
+ (0, fs_1.writeFileSync)(file, stripped);
80
+ result.changed.push(rel);
81
+ }
82
+ if (DANGEROUS_FIX_FLAG_USE.test(stripped)) {
83
+ result.skipped.push(`${rel} (a flag use that is not a plain argument remains — remove it by hand)`);
84
+ }
85
+ }
86
+ }
87
+ return result;
88
+ }
89
+ /**
90
+ * Heal the oxlint config filename of an app project: `oxlint.json` → `.oxlintrc.json`.
91
+ *
92
+ * oxlint only auto-discovers `.oxlintrc.json`. nuxt-base-template and
93
+ * nuxt-extensions shipped the config as `oxlint.json`, so every rule in it was
94
+ * silently ignored — projects linted with oxlint's defaults only. The templates
95
+ * renamed the file; existing projects keep the old name until this runs.
96
+ *
97
+ * Loading the config is not neutral. It turns on rules such as `no-console`, and
98
+ * an auto-fix run with `--fix-suggestions` applies that rule's suggestion, which
99
+ * deletes console calls. So the rename is GATED: it only happens once no check
100
+ * wrapper, script or lint-staged entry of the project still passes
101
+ * `--fix-suggestions`. Run it after `healCheckWrapper`, which removes the flag
102
+ * from the root wrapper in the same update.
103
+ *
104
+ * Same recoverability rules as the other heals: never through a symlink, never
105
+ * over uncommitted changes to the file, `git mv` for a tracked file so history
106
+ * follows the rename.
107
+ *
108
+ * @param appDir The app project (e.g. `<workspace>/projects/app`).
109
+ * @param workspaceRoot The monorepo root, whose `scripts/check.mjs` and
110
+ * `package.json` also gate the rename. Omit for a standalone app.
111
+ */
112
+ function healOxlintrcFilename(appDir, workspaceRoot) {
113
+ const legacy = (0, path_1.join)(appDir, LEGACY);
114
+ const target = (0, path_1.join)(appDir, TARGET);
115
+ if (!(0, fs_1.existsSync)(legacy) && !isSymlink(legacy)) {
116
+ return { action: 'none', changed: [] };
117
+ }
118
+ if ((0, fs_1.existsSync)(target) || isSymlink(target)) {
119
+ return {
120
+ action: 'both-present',
121
+ changed: [],
122
+ detail: `both ${LEGACY} and ${TARGET} exist — oxlint only loads ${TARGET}; merge the rules from ${LEGACY} by hand, then delete it`,
123
+ };
124
+ }
125
+ if (isSymlink(legacy)) {
126
+ return { action: 'skipped', changed: [], detail: `${LEGACY} is a symlink — rename it by hand` };
127
+ }
128
+ const withFlag = findDangerousFixFlagUsage(appDir, workspaceRoot);
129
+ if (withFlag.length > 0) {
130
+ return {
131
+ action: 'skipped',
132
+ changed: [],
133
+ detail: `--fix-suggestions/--fix-dangerously is still used in ${withFlag.join(', ')}; loading the config would let it delete console calls — remove the flag, then re-run`,
134
+ };
135
+ }
136
+ const tracked = isTracked(appDir, LEGACY);
137
+ if (tracked && hasUncommittedChanges(appDir, LEGACY)) {
138
+ return {
139
+ action: 'skipped',
140
+ changed: [],
141
+ detail: `${LEGACY} has uncommitted changes — commit or discard them, then re-run`,
142
+ };
143
+ }
144
+ if (tracked) {
145
+ (0, child_process_1.execFileSync)('git', ['-C', appDir, 'mv', '--', LEGACY, TARGET], { stdio: 'ignore' });
146
+ }
147
+ else {
148
+ // Nothing is overwritten (the target does not exist), so an untracked file
149
+ // keeps its content under the new name — no backup needed.
150
+ (0, fs_1.renameSync)(legacy, target);
151
+ }
152
+ const changed = [`${LEGACY} → ${TARGET}`];
153
+ const pkgPath = (0, path_1.join)(appDir, 'package.json');
154
+ if ((0, fs_1.existsSync)(pkgPath) && !isSymlink(pkgPath)) {
155
+ const pkg = (0, fs_1.readFileSync)(pkgPath, 'utf8');
156
+ // Textual, not JSON round-trip: only the flag value changes, the file keeps its formatting.
157
+ const updated = pkg.replace(CONFIG_FLAG, (_match, flag, sep, dot = '') => `${flag}${sep}${dot}${TARGET}`);
158
+ if (updated !== pkg) {
159
+ (0, fs_1.writeFileSync)(pkgPath, updated);
160
+ changed.push('package.json');
161
+ }
162
+ }
163
+ return { action: 'renamed', changed };
164
+ }
165
+ /**
166
+ * Files that may pass a dangerous fix flag and are ours to strip: both package.json
167
+ * files (scripts and inline lint-staged), lint-staged configs, and the app's own
168
+ * check wrapper.
169
+ */
170
+ function fixFlagFiles(appDir, workspaceRoot) {
171
+ const roots = [...new Set([workspaceRoot, appDir].filter((dir) => Boolean(dir)))];
172
+ return [
173
+ ...roots.flatMap((dir) => ['package.json', ...LINT_STAGED_FILES].map((name) => (0, path_1.join)(dir, name))),
174
+ (0, path_1.join)(appDir, 'scripts', 'check.mjs'),
175
+ ];
176
+ }
177
+ /** True when the TRACKED `relPath` has uncommitted modifications. */
178
+ function hasUncommittedChanges(dir, relPath) {
179
+ try {
180
+ const out = (0, child_process_1.execFileSync)('git', ['-C', dir, 'status', '--porcelain', '--', relPath], {
181
+ encoding: 'utf8',
182
+ stdio: ['ignore', 'pipe', 'ignore'],
183
+ });
184
+ return out.trim().length > 0;
185
+ }
186
+ catch (_a) {
187
+ return false;
188
+ }
189
+ }
190
+ /** True when `path` is a symlink (checked without following it). */
191
+ function isSymlink(path) {
192
+ try {
193
+ return (0, fs_1.lstatSync)(path).isSymbolicLink();
194
+ }
195
+ catch (_a) {
196
+ return false;
197
+ }
198
+ }
199
+ /** True when git tracks `relPath`; false outside a repository as well. */
200
+ function isTracked(dir, relPath) {
201
+ try {
202
+ (0, child_process_1.execFileSync)('git', ['-C', dir, 'ls-files', '--error-unmatch', '--', relPath], { stdio: 'ignore' });
203
+ return true;
204
+ }
205
+ catch (_a) {
206
+ return false;
207
+ }
208
+ }
209
+ /**
210
+ * `file` relative to `base`, always with forward slashes.
211
+ *
212
+ * These strings are reported to the user next to git facts ("not tracked by
213
+ * git", "uncommitted changes") and name files inside the repository, which git
214
+ * itself always spells with `/`. `relative()` returns the OS-native form, so on
215
+ * Windows the report would say `projects\app\package.json` while `git status`
216
+ * says `projects/app/package.json` for the same file. Falls back to the
217
+ * absolute path when there is no relative form (`base` IS the file).
218
+ */
219
+ function repoRelative(base, file) {
220
+ const rel = (0, path_1.relative)(base, file);
221
+ return rel ? rel.split(path_1.sep).join('/') : file;
222
+ }
223
+ function usesDangerousFixFlag(file) {
224
+ try {
225
+ // Follows a symlink on purpose: a linked config still feeds the lint run, so it
226
+ // must block the rename, even though the strip refuses to write through it.
227
+ return DANGEROUS_FIX_FLAG_USE.test((0, fs_1.readFileSync)(file, 'utf8'));
228
+ }
229
+ catch (_a) {
230
+ return false;
231
+ }
232
+ }