@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.
- package/bin/postinstall.js +28 -5
- package/build/commands/dev/doctor.js +45 -3
- package/build/commands/dev/up.js +3 -0
- package/build/commands/fullstack/update.js +31 -0
- package/build/lib/check-template-sync.js +127 -0
- package/build/lib/claude-cli.js +14 -25
- package/build/lib/codex-cli.js +24 -31
- package/build/lib/dev-env-bridge.js +14 -3
- package/build/lib/dev-env.js +117 -3
- package/build/lib/dev-patches.js +1 -1
- package/build/lib/dev-test-session.js +3 -0
- package/build/lib/ensure-root-dockerignore.js +7 -0
- package/build/lib/heal-check-wrapper.js +128 -11
- package/build/lib/heal-oxlintrc.js +215 -0
- package/build/lib/platform.js +107 -0
- package/build/lib/plugin-utils.js +9 -8
- package/build/templates/check/check.mjs +270 -268
- package/build/templates/check/lib/ansi.mjs +22 -0
- package/build/templates/check/lib/audit-report.mjs +412 -0
- package/build/templates/check/lib/workspace-packages.mjs +150 -0
- package/build/templates/check/lt-monorepo-pin.json +13 -0
- package/docs/commands.md +22 -11
- package/package.json +19 -14
package/build/lib/dev-env.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
package/build/lib/dev-patches.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
}
|