@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.
- package/bin/postinstall.js +28 -5
- package/build/commands/dev/doctor.js +45 -3
- package/build/commands/fullstack/update.js +31 -0
- package/build/commands/git/force-pull.js +11 -2
- package/build/commands/git/rebase.js +11 -1
- package/build/commands/git/reset.js +36 -4
- package/build/commands/git/update.js +42 -6
- package/build/extensions/api-mode.js +32 -8
- package/build/extensions/git.js +23 -25
- package/build/extensions/server.js +24 -17
- 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-api-launch.js +58 -6
- package/build/lib/dev-process.js +66 -30
- package/build/lib/dev-state.js +13 -2
- package/build/lib/dev-test-session.js +85 -20
- package/build/lib/git-env.js +46 -0
- package/build/lib/heal-check-wrapper.js +128 -11
- package/build/lib/heal-oxlintrc.js +232 -0
- package/build/lib/platform.js +107 -0
- package/build/lib/plugin-utils.js +9 -8
- package/build/lib/strip-vendor-schema-augmentation.js +13 -2
- package/build/lib/vendor-claude-md.js +9 -6
- package/build/lib/workspace-integration.js +12 -2
- 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 +1 -11
- package/package.json +13 -14
|
@@ -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 (`
|
|
39
|
-
* ts-node intermittently dies mid-run) and the App as the production Nitro
|
|
40
|
-
* (`nuxt build` → `node .output/server/index.mjs`, no Vite cold-compile).
|
|
41
|
-
* falls back to its dev runner (`pnpm start` / `pnpm dev`) when no build
|
|
42
|
-
* found.
|
|
43
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
// `ensureApiReachableOrSkip` guard (the API-readiness race).
|
|
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, (
|
|
432
|
+
const apiReady = yield (0, dev_process_1.waitForHttp)(`${apiUrl}/meta`, 120000, isStackServing, () => gone(pids.api));
|
|
404
433
|
if (!apiReady)
|
|
405
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|