@lenne.tech/cli 1.45.0 → 1.47.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.
@@ -14,6 +14,8 @@ exports.buildDevEnv = buildDevEnv;
14
14
  * - `NUXT_PUBLIC_*` lock the App to its own API
15
15
  * - `NUXT_PUBLIC_STORAGE_PREFIX` namespaces localStorage/sessionStorage
16
16
  * - `NSC__MONGOOSE__URI` / `DATABASE_URL` namespace the database per project
17
+ * - `NUXT_SESSION_PASSWORD` is keyed per slug, so a session cookie sealed by one
18
+ * stack cannot be unsealed by another (dev vs. `-test` vs. `-test-N` shard)
17
19
  *
18
20
  * CA trust for SSR fetches:
19
21
  * - Both API and App receive `NODE_EXTRA_CA_CERTS` pointing at the
@@ -21,6 +23,10 @@ exports.buildDevEnv = buildDevEnv;
21
23
  * subdomains succeed. Without this Nuxt SSR fails with "unable to
22
24
  * get local issuer certificate" when the app calls its own API.
23
25
  */
26
+ const node_crypto_1 = require("node:crypto");
27
+ const node_fs_1 = require("node:fs");
28
+ const node_os_1 = require("node:os");
29
+ const node_path_1 = require("node:path");
24
30
  const dev_env_bridge_1 = require("./dev-env-bridge");
25
31
  /**
26
32
  * Build the environment maps for both API and App processes.
@@ -29,7 +35,8 @@ const dev_env_bridge_1 = require("./dev-env-bridge");
29
35
  * vars survive. `lt dev`-managed keys win on top.
30
36
  */
31
37
  function buildDevEnv(input) {
32
- const { apiInternalPort, appInternalPort, baseEnv = {}, dbName, identity } = input;
38
+ var _a;
39
+ const { apiInternalPort, appDir, appInternalPort, baseEnv = {}, dbName, identity } = input;
33
40
  const apiSub = identity.subdomains.api;
34
41
  const appSub = identity.subdomains.app;
35
42
  const apiUrl = apiSub ? `https://${apiSub.hostname}` : '';
@@ -52,14 +59,18 @@ function buildDevEnv(input) {
52
59
  internalPort: apiInternalPort,
53
60
  },
54
61
  app: {
55
- env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
62
+ env: Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, baseEnv), sharedKeys), {
56
63
  // See API note above: pin the dev server to IPv4 so Caddy's
57
64
  // `127.0.0.1` upstream is unambiguous.
58
65
  HOST: '127.0.0.1', NITRO_HOST: '127.0.0.1' }), (apiUrl ? { API_URL: apiUrl, NUXT_API_URL: apiUrl, NUXT_PUBLIC_API_URL: apiUrl } : {})), (appUrl ? { NUXT_PUBLIC_SITE_URL: appUrl, SITE_URL: appUrl } : {})), {
59
66
  // Vite-API-Proxy is OFF by default in lt dev mode — Caddy serves
60
67
  // both subdomains under HTTPS with shared cookie domain, so
61
68
  // same-origin trickery is no longer required.
62
- NUXT_PUBLIC_API_PROXY: 'false', NUXT_PUBLIC_STORAGE_PREFIX: identity.slug, PORT: String(appInternalPort) }), (process.platform === 'darwin' ? { TMPDIR: '/tmp' } : {})),
69
+ NUXT_PUBLIC_API_PROXY: 'false', NUXT_PUBLIC_STORAGE_PREFIX: identity.slug }), (baseEnv.NUXT_SESSION_PASSWORD
70
+ ? {}
71
+ : {
72
+ NUXT_SESSION_PASSWORD: (_a = readEnvFileValue(appDir, 'NUXT_SESSION_PASSWORD')) !== null && _a !== void 0 ? _a : deriveSessionPassword(identity.slug),
73
+ })), { PORT: String(appInternalPort) }), (process.platform === 'darwin' ? { TMPDIR: '/tmp' } : {})),
63
74
  internalPort: appInternalPort,
64
75
  },
65
76
  };
@@ -68,3 +79,106 @@ function buildDevEnv(input) {
68
79
  function buildPostgresUrl(dbName) {
69
80
  return `postgresql://${dbName}:${dbName}@localhost:5432/${dbName}`;
70
81
  }
82
+ /**
83
+ * Stable local session password for a project's app process.
84
+ *
85
+ * 32 hex chars — h3's floor. Deterministic per slug AND per machine: the same stack on the
86
+ * same machine always gets the same value, so a restart does not invalidate open sessions,
87
+ * while a different slug (or a different developer's machine) gets a different one.
88
+ *
89
+ * The machine salt is what makes it unguessable. Without it the only input is the slug, which
90
+ * the app publishes itself via `NUXT_PUBLIC_STORAGE_PREFIX`, so anyone who loaded a page —
91
+ * including any visitor of a `lt dev tunnel` URL — could recompute the key and forge a sealed
92
+ * session cookie. With it, an attacker would have to read the developer's home directory.
93
+ */
94
+ function deriveSessionPassword(slug) {
95
+ var _a;
96
+ // No salt means the home directory is unwritable. Fall back to a constant key rather than a
97
+ // random one: determinism is the property every caller depends on, and a fresh value per run
98
+ // would log everybody out on every restart. The fallback is guessable — hence last resort.
99
+ return (0, node_crypto_1.createHmac)('sha256', (_a = machineSessionSalt()) !== null && _a !== void 0 ? _a : 'lt-dev')
100
+ .update(`lt-dev:session:${slug}`)
101
+ .digest('hex')
102
+ .slice(0, 32);
103
+ }
104
+ /**
105
+ * Read (or create) the machine-local salt at `~/.lenneTech/dev-session-salt`, mode 0600.
106
+ *
107
+ * Returns `null` when it can be neither read nor created — see {@link deriveSessionPassword}
108
+ * for what happens then.
109
+ */
110
+ function machineSessionSalt() {
111
+ const file = sessionSaltPath();
112
+ try {
113
+ const existing = (0, node_fs_1.readFileSync)(file, 'utf8').trim();
114
+ if (existing) {
115
+ return existing;
116
+ }
117
+ }
118
+ catch (_a) {
119
+ // Not created yet — fall through and create it.
120
+ }
121
+ try {
122
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(file), { recursive: true });
123
+ // `wx` fails when the file already exists, so two shards racing on a cold machine can
124
+ // never end up with two different salts: the loser falls into the catch and re-reads.
125
+ const salt = (0, node_crypto_1.randomBytes)(32).toString('hex');
126
+ (0, node_fs_1.writeFileSync)(file, `${salt}\n`, { flag: 'wx', mode: 0o600 });
127
+ return salt;
128
+ }
129
+ catch (_b) {
130
+ try {
131
+ return (0, node_fs_1.readFileSync)(file, 'utf8').trim() || null;
132
+ }
133
+ catch (_c) {
134
+ return null;
135
+ }
136
+ }
137
+ }
138
+ /**
139
+ * Read one key out of `<dir>/.env` without pulling in a dotenv dependency.
140
+ *
141
+ * Deliberately minimal: no interpolation, no multi-line values, no `.env.local` cascade. It
142
+ * exists to answer one question — did the project set this key itself? — for a value the built
143
+ * Nitro server would otherwise never see.
144
+ */
145
+ function readEnvFileValue(dir, key) {
146
+ if (!dir) {
147
+ return undefined;
148
+ }
149
+ let content;
150
+ try {
151
+ content = (0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, '.env'), 'utf8');
152
+ }
153
+ catch (_a) {
154
+ return undefined;
155
+ }
156
+ for (const line of content.split(/\r?\n/)) {
157
+ const trimmed = line.trim();
158
+ if (!trimmed || trimmed.startsWith('#')) {
159
+ continue;
160
+ }
161
+ const eq = trimmed.indexOf('=');
162
+ if (eq === -1) {
163
+ continue;
164
+ }
165
+ if (trimmed
166
+ .slice(0, eq)
167
+ .replace(/^export\s+/, '')
168
+ .trim() !== key) {
169
+ continue;
170
+ }
171
+ const raw = trimmed.slice(eq + 1).trim();
172
+ const quoted = raw.length >= 2 && ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'")));
173
+ return (quoted ? raw.slice(1, -1) : raw) || undefined;
174
+ }
175
+ return undefined;
176
+ }
177
+ /**
178
+ * Path of the machine-local salt. `LT_DEV_SESSION_SALT_PATH` overrides it, and `HOME` is read
179
+ * before `os.homedir()` — both so tests can redirect the write to a tmpdir, the same reason
180
+ * `dev-service.ts#userHome` does it.
181
+ */
182
+ function sessionSaltPath() {
183
+ return process.env.LT_DEV_SESSION_SALT_PATH || (0, node_path_1.join)(process.env.HOME || (0, node_os_1.homedir)(), '.lenneTech', 'dev-session-salt');
184
+ }
@@ -157,7 +157,7 @@ function patchClaudeMd(file, options) {
157
157
  if (dbName)
158
158
  lines.push(`- DB: \`mongodb://127.0.0.1/${dbName}\``);
159
159
  lines.push('');
160
- lines.push('Env vars set automatically by `lt dev up`: `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_SITE_URL`, `NUXT_PUBLIC_STORAGE_PREFIX`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Never assume `localhost:3000` / `localhost:3001` for this project** — those are the framework defaults, not the active URLs.');
160
+ lines.push('Env vars set automatically by `lt dev up`: `BASE_URL`, `APP_URL`, `NUXT_API_URL`, `NUXT_PUBLIC_API_URL`, `NUXT_PUBLIC_SITE_URL`, `NUXT_PUBLIC_STORAGE_PREFIX`, `NUXT_SESSION_PASSWORD`, `NSC__MONGOOSE__URI`, `DATABASE_URL`. **Never assume `localhost:3000` / `localhost:3001` for this project** — those are the framework defaults, not the active URLs.');
161
161
  lines.push('');
162
162
  lines.push(endMarker);
163
163
  const block = lines.join('\n');
@@ -274,6 +274,9 @@ function bringUpTestSession(layout_1, baseIdentity_1, log_1) {
274
274
  log.info('');
275
275
  const devEnv = (0, dev_env_1.buildDevEnv)({
276
276
  apiInternalPort: apiPort !== null && apiPort !== void 0 ? apiPort : 0,
277
+ // The built server this stack runs reads no `.env` at all, so a key the project set
278
+ // there has to be forwarded explicitly — that is the whole reason this argument exists.
279
+ appDir: layout.appDir,
277
280
  appInternalPort: appPort !== null && appPort !== void 0 ? appPort : 0,
278
281
  baseEnv: process.env,
279
282
  dbName,
@@ -16,8 +16,15 @@ exports.ensureRootDockerignore = ensureRootDockerignore;
16
16
  // bundle itself changes.
17
17
  const REQUIRED_PATTERNS = [
18
18
  '**/node_modules',
19
+ // `**/.output` matches a path component EXACTLY, so the isolated build dirs of
20
+ // `lt dev test` (`.output-test`, `.nuxt-test`, `.nuxt-test-2`, …) need their own
21
+ // globs. They are built with the app's full dev env, so Nitro freezes values like
22
+ // `NUXT_SESSION_PASSWORD` into them as runtime-config defaults — and a `--keep`
23
+ // run or a crashed teardown leaves the tree on disk for the next `docker build`.
19
24
  '**/.output',
25
+ '**/.output-*',
20
26
  '**/.nuxt',
27
+ '**/.nuxt-*',
21
28
  '**/dist',
22
29
  // `**/.env` matches a path component EXACTLY, so it does NOT cover
23
30
  // `.env.production` / `.env.staging` / `.env.test` — all of which routinely
@@ -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,215 @@
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 = [...(workspaceRoot ? [(0, path_1.join)(workspaceRoot, 'scripts', 'check.mjs')] : []), ...fixFlagFiles(appDir, workspaceRoot)];
42
+ return [...new Set(files)].filter((file) => usesDangerousFixFlag(file)).map((file) => (0, path_1.relative)(base, file) || file);
43
+ }
44
+ /**
45
+ * Remove `--fix-suggestions` and `--fix-dangerously` from the project's lint commands
46
+ * — root and app `package.json` (scripts and inline lint-staged), lint-staged configs
47
+ * and the app's own `scripts/check.mjs` — so the oxlint config can be loaded safely.
48
+ *
49
+ * Textual, so formatting stays exactly as it was. Only tracked files without
50
+ * uncommitted changes are edited (git can restore them); an untracked, dirty or
51
+ * symlinked file is reported in `skipped` and keeps blocking the rename.
52
+ */
53
+ function healDangerousOxlintFixFlags(appDir, workspaceRoot) {
54
+ const base = workspaceRoot !== null && workspaceRoot !== void 0 ? workspaceRoot : appDir;
55
+ const result = { changed: [], skipped: [] };
56
+ for (const file of fixFlagFiles(appDir, workspaceRoot)) {
57
+ if (!usesDangerousFixFlag(file)) {
58
+ continue;
59
+ }
60
+ const rel = (0, path_1.relative)(base, file) || file;
61
+ const dir = (0, path_1.join)(file, '..');
62
+ const name = (0, path_1.relative)(dir, file);
63
+ if (isSymlink(file)) {
64
+ result.skipped.push(`${rel} (symlink)`);
65
+ }
66
+ else if (!isTracked(dir, name)) {
67
+ result.skipped.push(`${rel} (not tracked by git — remove the flag by hand)`);
68
+ }
69
+ else if (hasUncommittedChanges(dir, name)) {
70
+ result.skipped.push(`${rel} (uncommitted changes — commit or discard them, then re-run)`);
71
+ }
72
+ else {
73
+ const content = (0, fs_1.readFileSync)(file, 'utf8');
74
+ const stripped = content.replace(DANGEROUS_FIX_FLAG_ARG, '');
75
+ if (stripped !== content) {
76
+ (0, fs_1.writeFileSync)(file, stripped);
77
+ result.changed.push(rel);
78
+ }
79
+ if (DANGEROUS_FIX_FLAG_USE.test(stripped)) {
80
+ result.skipped.push(`${rel} (a flag use that is not a plain argument remains — remove it by hand)`);
81
+ }
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+ /**
87
+ * Heal the oxlint config filename of an app project: `oxlint.json` → `.oxlintrc.json`.
88
+ *
89
+ * oxlint only auto-discovers `.oxlintrc.json`. nuxt-base-template and
90
+ * nuxt-extensions shipped the config as `oxlint.json`, so every rule in it was
91
+ * silently ignored — projects linted with oxlint's defaults only. The templates
92
+ * renamed the file; existing projects keep the old name until this runs.
93
+ *
94
+ * Loading the config is not neutral. It turns on rules such as `no-console`, and
95
+ * an auto-fix run with `--fix-suggestions` applies that rule's suggestion, which
96
+ * deletes console calls. So the rename is GATED: it only happens once no check
97
+ * wrapper, script or lint-staged entry of the project still passes
98
+ * `--fix-suggestions`. Run it after `healCheckWrapper`, which removes the flag
99
+ * from the root wrapper in the same update.
100
+ *
101
+ * Same recoverability rules as the other heals: never through a symlink, never
102
+ * over uncommitted changes to the file, `git mv` for a tracked file so history
103
+ * follows the rename.
104
+ *
105
+ * @param appDir The app project (e.g. `<workspace>/projects/app`).
106
+ * @param workspaceRoot The monorepo root, whose `scripts/check.mjs` and
107
+ * `package.json` also gate the rename. Omit for a standalone app.
108
+ */
109
+ function healOxlintrcFilename(appDir, workspaceRoot) {
110
+ const legacy = (0, path_1.join)(appDir, LEGACY);
111
+ const target = (0, path_1.join)(appDir, TARGET);
112
+ if (!(0, fs_1.existsSync)(legacy) && !isSymlink(legacy)) {
113
+ return { action: 'none', changed: [] };
114
+ }
115
+ if ((0, fs_1.existsSync)(target) || isSymlink(target)) {
116
+ return {
117
+ action: 'both-present',
118
+ changed: [],
119
+ detail: `both ${LEGACY} and ${TARGET} exist — oxlint only loads ${TARGET}; merge the rules from ${LEGACY} by hand, then delete it`,
120
+ };
121
+ }
122
+ if (isSymlink(legacy)) {
123
+ return { action: 'skipped', changed: [], detail: `${LEGACY} is a symlink — rename it by hand` };
124
+ }
125
+ const withFlag = findDangerousFixFlagUsage(appDir, workspaceRoot);
126
+ if (withFlag.length > 0) {
127
+ return {
128
+ action: 'skipped',
129
+ changed: [],
130
+ 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`,
131
+ };
132
+ }
133
+ const tracked = isTracked(appDir, LEGACY);
134
+ if (tracked && hasUncommittedChanges(appDir, LEGACY)) {
135
+ return {
136
+ action: 'skipped',
137
+ changed: [],
138
+ detail: `${LEGACY} has uncommitted changes — commit or discard them, then re-run`,
139
+ };
140
+ }
141
+ if (tracked) {
142
+ (0, child_process_1.execFileSync)('git', ['-C', appDir, 'mv', '--', LEGACY, TARGET], { stdio: 'ignore' });
143
+ }
144
+ else {
145
+ // Nothing is overwritten (the target does not exist), so an untracked file
146
+ // keeps its content under the new name — no backup needed.
147
+ (0, fs_1.renameSync)(legacy, target);
148
+ }
149
+ const changed = [`${LEGACY} → ${TARGET}`];
150
+ const pkgPath = (0, path_1.join)(appDir, 'package.json');
151
+ if ((0, fs_1.existsSync)(pkgPath) && !isSymlink(pkgPath)) {
152
+ const pkg = (0, fs_1.readFileSync)(pkgPath, 'utf8');
153
+ // Textual, not JSON round-trip: only the flag value changes, the file keeps its formatting.
154
+ const updated = pkg.replace(CONFIG_FLAG, (_match, flag, sep, dot = '') => `${flag}${sep}${dot}${TARGET}`);
155
+ if (updated !== pkg) {
156
+ (0, fs_1.writeFileSync)(pkgPath, updated);
157
+ changed.push('package.json');
158
+ }
159
+ }
160
+ return { action: 'renamed', changed };
161
+ }
162
+ /**
163
+ * Files that may pass a dangerous fix flag and are ours to strip: both package.json
164
+ * files (scripts and inline lint-staged), lint-staged configs, and the app's own
165
+ * check wrapper.
166
+ */
167
+ function fixFlagFiles(appDir, workspaceRoot) {
168
+ const roots = [...new Set([workspaceRoot, appDir].filter((dir) => Boolean(dir)))];
169
+ return [
170
+ ...roots.flatMap((dir) => ['package.json', ...LINT_STAGED_FILES].map((name) => (0, path_1.join)(dir, name))),
171
+ (0, path_1.join)(appDir, 'scripts', 'check.mjs'),
172
+ ];
173
+ }
174
+ /** True when the TRACKED `relPath` has uncommitted modifications. */
175
+ function hasUncommittedChanges(dir, relPath) {
176
+ try {
177
+ const out = (0, child_process_1.execFileSync)('git', ['-C', dir, 'status', '--porcelain', '--', relPath], {
178
+ encoding: 'utf8',
179
+ stdio: ['ignore', 'pipe', 'ignore'],
180
+ });
181
+ return out.trim().length > 0;
182
+ }
183
+ catch (_a) {
184
+ return false;
185
+ }
186
+ }
187
+ /** True when `path` is a symlink (checked without following it). */
188
+ function isSymlink(path) {
189
+ try {
190
+ return (0, fs_1.lstatSync)(path).isSymbolicLink();
191
+ }
192
+ catch (_a) {
193
+ return false;
194
+ }
195
+ }
196
+ /** True when git tracks `relPath`; false outside a repository as well. */
197
+ function isTracked(dir, relPath) {
198
+ try {
199
+ (0, child_process_1.execFileSync)('git', ['-C', dir, 'ls-files', '--error-unmatch', '--', relPath], { stdio: 'ignore' });
200
+ return true;
201
+ }
202
+ catch (_a) {
203
+ return false;
204
+ }
205
+ }
206
+ function usesDangerousFixFlag(file) {
207
+ try {
208
+ // Follows a symlink on purpose: a linked config still feeds the lint run, so it
209
+ // must block the rename, even though the strip refuses to write through it.
210
+ return DANGEROUS_FIX_FLAG_USE.test((0, fs_1.readFileSync)(file, 'utf8'));
211
+ }
212
+ catch (_a) {
213
+ return false;
214
+ }
215
+ }