@lenne.tech/cli 1.43.0 → 1.45.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/build/commands/dev/doctor.js +30 -7
- package/build/commands/fullstack/add-api.js +6 -1
- package/build/commands/fullstack/add-app.js +6 -1
- package/build/commands/fullstack/init.js +7 -1
- package/build/commands/fullstack/update.js +24 -1
- package/build/config/vendor-runtime-deps.json +2 -3
- package/build/extensions/server.js +19 -8
- package/build/lib/heal-check-wrapper.js +189 -27
- package/build/lib/hoist-workspace-pnpm-config.js +284 -6
- package/build/lib/workspace-integration.js +36 -1
- package/build/templates/check/build-test-gate.mjs +107 -0
- package/build/templates/check/check.mjs +422 -52
- package/docs/commands.md +2 -2
- package/package.json +1 -1
|
@@ -154,23 +154,46 @@ const DoctorCommand = {
|
|
|
154
154
|
else if (gs.file && gs.hasDbReset) {
|
|
155
155
|
line('OK', colors.green, 'global-setup allow-list is ticket + shard safe');
|
|
156
156
|
}
|
|
157
|
-
// 7.5 check
|
|
157
|
+
// 7.5 check wrapper drift: the root wrapper is canonical (bundled with the
|
|
158
158
|
// CLI, synced by `lt fullstack update`). A diverged copy silently
|
|
159
159
|
// misses fixes (idle-watchdog, install hoisting, summed test
|
|
160
160
|
// metrics) — surface it instead of letting copies drift apart.
|
|
161
|
+
//
|
|
162
|
+
// Checked over the WHOLE copy set, not just `check.mjs`: the wrapper
|
|
163
|
+
// imports siblings, so a missing or stale one makes `check` die with
|
|
164
|
+
// ERR_MODULE_NOT_FOUND (or an import mismatch) before running a single
|
|
165
|
+
// step. Reporting only on `check.mjs` meant doctor printed a green
|
|
166
|
+
// "matches the canonical CLI version" for a project whose `check` was
|
|
167
|
+
// completely broken — and doctor is the tool people reach for exactly
|
|
168
|
+
// then.
|
|
161
169
|
try {
|
|
162
170
|
const { readFileSync: read } = yield Promise.resolve().then(() => __importStar(require('fs')));
|
|
163
171
|
const { join: j } = yield Promise.resolve().then(() => __importStar(require('path')));
|
|
164
|
-
const
|
|
172
|
+
const { resolveCopySet } = yield Promise.resolve().then(() => __importStar(require('../../lib/heal-check-wrapper')));
|
|
165
173
|
const bundledCheck = j(__dirname, '..', '..', 'templates', 'check', 'check.mjs');
|
|
166
|
-
if (filesystem.exists(
|
|
167
|
-
|
|
168
|
-
|
|
174
|
+
if (filesystem.exists(bundledCheck)) {
|
|
175
|
+
const missing = [];
|
|
176
|
+
const drifted = [];
|
|
177
|
+
for (const { rel, source } of resolveCopySet(bundledCheck)) {
|
|
178
|
+
const target = j(layout.root, rel);
|
|
179
|
+
if (!filesystem.exists(target)) {
|
|
180
|
+
missing.push(rel);
|
|
181
|
+
}
|
|
182
|
+
else if (read(target, 'utf8') !== read(source, 'utf8')) {
|
|
183
|
+
drifted.push(rel);
|
|
184
|
+
}
|
|
169
185
|
}
|
|
170
|
-
|
|
171
|
-
line('
|
|
186
|
+
if (missing.length > 0) {
|
|
187
|
+
line('ERROR', colors.red, `check wrapper incomplete — missing ${missing.join(', ')}`);
|
|
188
|
+
line('ERROR', colors.red, ' `pnpm run check` cannot start; run `lt fullstack update` to install it');
|
|
189
|
+
}
|
|
190
|
+
else if (drifted.length > 0) {
|
|
191
|
+
line('WARN', colors.yellow, `${drifted.join(', ')} differs from the canonical CLI version`);
|
|
172
192
|
line('WARN', colors.yellow, ' run `lt fullstack update` to sync it (skips uncommitted local edits)');
|
|
173
193
|
}
|
|
194
|
+
else if (filesystem.exists(j(layout.root, 'scripts', 'check.mjs'))) {
|
|
195
|
+
line('OK', colors.green, 'check wrapper matches the canonical CLI version');
|
|
196
|
+
}
|
|
174
197
|
}
|
|
175
198
|
}
|
|
176
199
|
catch (_a) {
|
|
@@ -330,7 +330,11 @@ const NewCommand = {
|
|
|
330
330
|
// non-root level pnpm ignores), drop nested lockfiles the root supersedes,
|
|
331
331
|
// and guarantee a root `.dockerignore` (Docker never reads a sub-project's
|
|
332
332
|
// own when building from the root context).
|
|
333
|
-
|
|
333
|
+
// A disagreement between the two repos is not something the merge may decide
|
|
334
|
+
// silently. Reported here, and repeated in the closing block — this call site
|
|
335
|
+
// sits directly above the install output that would otherwise bury it.
|
|
336
|
+
const { conflicts } = (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
|
|
337
|
+
(0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
|
|
334
338
|
// Run install + format unless explicitly skipped (CI/agents may
|
|
335
339
|
// want to chain multiple add-* calls before installing once).
|
|
336
340
|
if (!skipInstall && !experimental) {
|
|
@@ -357,6 +361,7 @@ const NewCommand = {
|
|
|
357
361
|
info('');
|
|
358
362
|
success(`API integrated into ${workspaceDir} in ${toolbox.helper.msToMinutesAndSeconds(timer())}m.`);
|
|
359
363
|
info('');
|
|
364
|
+
(0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
|
|
360
365
|
info('Next:');
|
|
361
366
|
if (experimental) {
|
|
362
367
|
info(` $ cd ${workspaceDir}/projects/api && bun install`);
|
|
@@ -259,7 +259,11 @@ const NewCommand = {
|
|
|
259
259
|
// carry pnpm.overrides too), drop the frontend starter's standalone
|
|
260
260
|
// pnpm-lock.yaml the root supersedes, and guarantee a root `.dockerignore`
|
|
261
261
|
// (else the image inherits the host's node_modules, .output and .env).
|
|
262
|
-
|
|
262
|
+
// A disagreement between the two repos is not something the merge may decide
|
|
263
|
+
// silently. Reported here, and repeated in the closing block — this call site
|
|
264
|
+
// sits directly above the install output that would otherwise bury it.
|
|
265
|
+
const { conflicts } = (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir: workspaceDir });
|
|
266
|
+
(0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
|
|
263
267
|
if (!skipInstall) {
|
|
264
268
|
const installSpinner = spin('Install workspace packages');
|
|
265
269
|
try {
|
|
@@ -278,6 +282,7 @@ const NewCommand = {
|
|
|
278
282
|
info('');
|
|
279
283
|
success(`App integrated into ${workspaceDir} in ${toolbox.helper.msToMinutesAndSeconds(timer())}m.`);
|
|
280
284
|
info('');
|
|
285
|
+
(0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
|
|
281
286
|
info('Next:');
|
|
282
287
|
info(` $ cd ${workspaceDir}`);
|
|
283
288
|
info(` $ ${toolbox.pm.run('start')}`);
|
|
@@ -653,7 +653,12 @@ const NewCommand = {
|
|
|
653
653
|
// guarantees a root `.dockerignore` (else the images carry the host's
|
|
654
654
|
// node_modules, a stale .output, and a local .env that `nuxt build`
|
|
655
655
|
// bakes in).
|
|
656
|
-
|
|
656
|
+
// Two sources setting the same pnpm key to different values is not a merge
|
|
657
|
+
// to resolve — it is a disagreement between two repos, and the merge would
|
|
658
|
+
// pick one silently. Say so here, where both halves are still visible, and
|
|
659
|
+
// again at the end (the install output below buries this one).
|
|
660
|
+
const { conflicts } = (0, workspace_integration_1.finalizeWorkspaceRoot)({ filesystem, projectDir });
|
|
661
|
+
(0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
|
|
657
662
|
// Install all packages
|
|
658
663
|
if (!experimental) {
|
|
659
664
|
const installSpinner = spin('Install all packages');
|
|
@@ -734,6 +739,7 @@ const NewCommand = {
|
|
|
734
739
|
info('');
|
|
735
740
|
success(`Generated fullstack workspace with ${frontend} in ${projectDir} with ${name} app in ${helper.msToMinutesAndSeconds(timer())}m.`);
|
|
736
741
|
info('');
|
|
742
|
+
(0, workspace_integration_1.reportWorkspaceConflicts)(conflicts, toolbox.print.warning);
|
|
737
743
|
info('Next:');
|
|
738
744
|
if (experimental) {
|
|
739
745
|
info(` $ cd ${projectDir}`);
|
|
@@ -198,7 +198,20 @@ const NewCommand = {
|
|
|
198
198
|
const changedCheck = (0, heal_check_wrapper_1.healCheckWrapper)(cwd, checkAsset);
|
|
199
199
|
if (changedCheck.length > 0) {
|
|
200
200
|
info('');
|
|
201
|
-
success
|
|
201
|
+
// A skip entry is NOT a success — it means the wrapper stayed on its old
|
|
202
|
+
// version. Reporting the whole list through `success()` painted a refusal
|
|
203
|
+
// green.
|
|
204
|
+
const skipped = changedCheck.filter((entry) => entry.includes('skipped'));
|
|
205
|
+
const applied = changedCheck.filter((entry) => !entry.includes('skipped'));
|
|
206
|
+
if (applied.length > 0) {
|
|
207
|
+
success(` Installed/updated the check wrapper: ${applied.join(', ')}`);
|
|
208
|
+
info(' `check` now serialises build/typecheck against the test suites,');
|
|
209
|
+
info(' so it takes longer in wall-clock but no longer destabilises API e2e runs.');
|
|
210
|
+
info(' The wrapper imports its siblings — keep them together, or `check` will not start.');
|
|
211
|
+
}
|
|
212
|
+
for (const entry of skipped) {
|
|
213
|
+
warning(` Check wrapper NOT updated: ${entry}`);
|
|
214
|
+
}
|
|
202
215
|
}
|
|
203
216
|
// ── Self-heal: keep `.lt-dev/` out of git ──────────────────────────────
|
|
204
217
|
//
|
|
@@ -209,6 +222,16 @@ const NewCommand = {
|
|
|
209
222
|
info('');
|
|
210
223
|
success(' Added `.lt-dev/` to .gitignore');
|
|
211
224
|
}
|
|
225
|
+
// ── Self-heal: keep the check's isolated Nuxt build dir out of git ──────
|
|
226
|
+
//
|
|
227
|
+
// The check wrapper pins `NUXT_BUILD_DIR=.nuxt-check` so it never writes the
|
|
228
|
+
// `.nuxt/` a parked `nuxt dev` reads. Current starters already ignore it
|
|
229
|
+
// (via their `.nuxt-*` glob); projects scaffolded before that glob do not,
|
|
230
|
+
// and a build dir is a plausible place for a resolved runtimeConfig to be
|
|
231
|
+
// committed by accident. Idempotent.
|
|
232
|
+
if ((0, dev_patches_1.addToGitignore)(cwd, '.nuxt-check')) {
|
|
233
|
+
success(' Added `.nuxt-check` to .gitignore');
|
|
234
|
+
}
|
|
212
235
|
// ── Self-heal: repair the vendor-mode migration store ──────────────────
|
|
213
236
|
//
|
|
214
237
|
// `migrations-utils/migrate.js` is written ONCE, at conversion time. Projects
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$
|
|
3
|
-
"
|
|
4
|
-
"runtimeHelpers": ["find-file-up"]
|
|
2
|
+
"$comment": "Upstream @lenne.tech/nest-server DEVDEPENDENCIES that must land in a vendored project's `dependencies`. Vendoring copies upstream `dependencies` verbatim and does not read `peerDependencies` at all, so a package the framework imports at runtime while keeping it in devDependencies would never reach the production runtime. Entries here are matched against upstream `devDependencies` only — see `isVendorRuntimeDep`, consulted in the `upstreamDevDeps` loop in `extensions/server.ts` — and promoted into `dependencies`, preferring the upstream peer RANGE over the exact devDep pin where one exists, so a generated project still receives patch-level security fixes. A package that appears ONLY in upstream `peerDependencies` cannot be promoted by this list: listing it here is a silent no-op. Two live cases: `find-file-up` (imported by the config loader, devDependency only), and the better-auth trio (declared as peerDependencies AND mirrored into devDependencies upstream; the mirror is what makes the promotion fire, so this list is the fallback if the starter ever stops declaring them itself).",
|
|
3
|
+
"runtimeHelpers": ["@better-auth/core", "@better-auth/passkey", "better-auth", "find-file-up"]
|
|
5
4
|
}
|
|
@@ -951,6 +951,7 @@ class Server {
|
|
|
951
951
|
*/
|
|
952
952
|
convertCloneToVendored(options) {
|
|
953
953
|
return __awaiter(this, void 0, void 0, function* () {
|
|
954
|
+
var _a;
|
|
954
955
|
const { dest, upstreamBranch, upstreamRepoUrl = 'https://github.com/lenneTech/nest-server.git' } = options;
|
|
955
956
|
const filesystem = this.filesystem;
|
|
956
957
|
const { system } = this.toolbox;
|
|
@@ -995,16 +996,22 @@ class Server {
|
|
|
995
996
|
// transitive deps into the project's package.json (step 5 below).
|
|
996
997
|
let upstreamDeps = {};
|
|
997
998
|
let upstreamDevDeps = {};
|
|
999
|
+
let upstreamPeerDeps = {};
|
|
998
1000
|
let upstreamVersion = '';
|
|
999
1001
|
try {
|
|
1000
1002
|
const upstreamPkg = filesystem.read(`${tmpClone}/package.json`, 'json');
|
|
1001
1003
|
if (upstreamPkg && typeof upstreamPkg === 'object') {
|
|
1002
1004
|
upstreamDeps = upstreamPkg.dependencies || {};
|
|
1003
1005
|
upstreamDevDeps = upstreamPkg.devDependencies || {};
|
|
1006
|
+
// Read for the version RANGE only, never as a source of packages: the
|
|
1007
|
+
// promotion below still keys off devDependencies. A peer range is the
|
|
1008
|
+
// spec upstream actually supports, whereas the devDep entry beside it is
|
|
1009
|
+
// an exact pin that exists for the framework's own CI.
|
|
1010
|
+
upstreamPeerDeps = upstreamPkg.peerDependencies || {};
|
|
1004
1011
|
upstreamVersion = upstreamPkg.version || '';
|
|
1005
1012
|
}
|
|
1006
1013
|
}
|
|
1007
|
-
catch (
|
|
1014
|
+
catch (_b) {
|
|
1008
1015
|
// Best-effort — if we can't read upstream pkg, the starter's own
|
|
1009
1016
|
// deps should still cover most of the framework's needs.
|
|
1010
1017
|
}
|
|
@@ -1018,7 +1025,7 @@ class Server {
|
|
|
1018
1025
|
try {
|
|
1019
1026
|
upstreamLockRaw = filesystem.read(`${tmpClone}/pnpm-lock.yaml`) || '';
|
|
1020
1027
|
}
|
|
1021
|
-
catch (
|
|
1028
|
+
catch (_c) {
|
|
1022
1029
|
// Best-effort — without the lockfile, the closure step falls back to
|
|
1023
1030
|
// the upstream package.json ranges (still better than nothing).
|
|
1024
1031
|
}
|
|
@@ -1034,7 +1041,7 @@ class Server {
|
|
|
1034
1041
|
try {
|
|
1035
1042
|
upstreamWorkspaceYaml = filesystem.read(`${tmpClone}/pnpm-workspace.yaml`) || '';
|
|
1036
1043
|
}
|
|
1037
|
-
catch (
|
|
1044
|
+
catch (_d) {
|
|
1038
1045
|
// Best-effort: an older framework revision may predate the file. The project
|
|
1039
1046
|
// then keeps exactly the allowlist its own template shipped.
|
|
1040
1047
|
}
|
|
@@ -1050,7 +1057,7 @@ class Server {
|
|
|
1050
1057
|
upstreamClaudeMd = claudeMdContent;
|
|
1051
1058
|
}
|
|
1052
1059
|
}
|
|
1053
|
-
catch (
|
|
1060
|
+
catch (_e) {
|
|
1054
1061
|
// Non-fatal — if missing, the project CLAUDE.md just won't get upstream sections.
|
|
1055
1062
|
}
|
|
1056
1063
|
// Snapshot the upstream commit SHA for traceability in VENDOR.md.
|
|
@@ -1059,7 +1066,7 @@ class Server {
|
|
|
1059
1066
|
const sha = yield system.run(`git -C ${tmpClone} rev-parse HEAD`);
|
|
1060
1067
|
upstreamCommit = (sha || '').trim();
|
|
1061
1068
|
}
|
|
1062
|
-
catch (
|
|
1069
|
+
catch (_f) {
|
|
1063
1070
|
// Non-fatal — VENDOR.md will just show an empty SHA.
|
|
1064
1071
|
}
|
|
1065
1072
|
try {
|
|
@@ -1342,7 +1349,7 @@ class Server {
|
|
|
1342
1349
|
}
|
|
1343
1350
|
}
|
|
1344
1351
|
}
|
|
1345
|
-
catch (
|
|
1352
|
+
catch (_g) {
|
|
1346
1353
|
// skip unreadable file
|
|
1347
1354
|
}
|
|
1348
1355
|
}
|
|
@@ -1484,9 +1491,13 @@ class Server {
|
|
|
1484
1491
|
pkg.devDependencies = {};
|
|
1485
1492
|
const devDeps = pkg.devDependencies;
|
|
1486
1493
|
for (const [depName, depVersion] of Object.entries(upstreamDevDeps)) {
|
|
1487
|
-
// Runtime-needed devDeps → promote to dependencies
|
|
1494
|
+
// Runtime-needed devDeps → promote to dependencies. Prefer the peer
|
|
1495
|
+
// RANGE when upstream declares one: the devDep entry beside it is an
|
|
1496
|
+
// exact pin for the framework's own CI, and baking that into a
|
|
1497
|
+
// generated project means `pnpm update` never delivers a patch-level
|
|
1498
|
+
// fix — the wrong default for an auth library like better-auth.
|
|
1488
1499
|
if (this.isVendorRuntimeDep(depName) && !(depName in deps)) {
|
|
1489
|
-
deps[depName] = depVersion;
|
|
1500
|
+
deps[depName] = (_a = upstreamPeerDeps[depName]) !== null && _a !== void 0 ? _a : depVersion;
|
|
1490
1501
|
continue;
|
|
1491
1502
|
}
|
|
1492
1503
|
// @types/<pkg> where <pkg> is a runtime dep → accept as devDep
|
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.healCheckWrapper = healCheckWrapper;
|
|
4
|
+
exports.resolveCopySet = resolveCopySet;
|
|
4
5
|
const child_process_1 = require("child_process");
|
|
5
6
|
const fs_1 = require("fs");
|
|
6
7
|
const path_1 = require("path");
|
|
7
8
|
/** Marker value for the report-driven check wrapper. */
|
|
8
9
|
const WRAPPER = 'node scripts/check.mjs';
|
|
10
|
+
/** Where the wrapper and its imports live inside a project. */
|
|
11
|
+
const SCRIPTS_DIR = 'scripts';
|
|
9
12
|
/**
|
|
10
|
-
* Idempotently install the report-driven
|
|
13
|
+
* Idempotently install the report-driven check wrapper — and every module it
|
|
14
|
+
* imports — into a project.
|
|
11
15
|
*
|
|
12
|
-
* Copies the bundled
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
+
* Copies the bundled wrapper to `<root>/scripts/check.mjs`, copies its whole
|
|
17
|
+
* relative-import closure alongside it under the names the wrapper imports them
|
|
18
|
+
* by, and rewrites the root `package.json` so that `check` runs the wrapper
|
|
19
|
+
* while the original chain is preserved as `check:raw`. A no-op once already
|
|
20
|
+
* wired (so it is safe to run on every `lt fullstack update`).
|
|
16
21
|
*
|
|
17
22
|
* `lt fullstack init` already ships the wrapper via the template clone; this is
|
|
18
23
|
* the MIGRATION path that brings it into pre-existing projects.
|
|
19
24
|
*
|
|
25
|
+
* The copy set moves ATOMICALLY: if any member must be skipped, none are
|
|
26
|
+
* written. A partial update would leave `check.mjs` and a sibling on different
|
|
27
|
+
* versions, and the project's `check` then dies on an import mismatch before
|
|
28
|
+
* running a single step.
|
|
29
|
+
*
|
|
20
30
|
* @param projectRoot Absolute path to the (workspace) project root.
|
|
21
|
-
* @param assetPath Absolute path to the bundled
|
|
31
|
+
* @param assetPath Absolute path to the bundled wrapper. Its DIRECTORY is
|
|
32
|
+
* also probed: every module the wrapper imports relatively
|
|
33
|
+
* (transitively) is shipped from there. The asset always
|
|
34
|
+
* lands as `scripts/check.mjs` regardless of its own name.
|
|
22
35
|
* @returns The list of changed file paths (relative to `projectRoot`); empty when nothing changed.
|
|
23
36
|
*/
|
|
24
37
|
function healCheckWrapper(projectRoot, assetPath) {
|
|
@@ -39,22 +52,35 @@ function healCheckWrapper(projectRoot, assetPath) {
|
|
|
39
52
|
if (!scripts || typeof scripts.check !== 'string') {
|
|
40
53
|
return changed;
|
|
41
54
|
}
|
|
42
|
-
// 1. Ensure
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
// 1. Ensure the wrapper — and everything it imports — exists in the project
|
|
56
|
+
// and matches the canonical version.
|
|
57
|
+
//
|
|
58
|
+
// The set is derived from the wrapper's own import statements rather than a
|
|
59
|
+
// hard-coded name: the wrapper grew a sibling (`build-test-gate.mjs`, which
|
|
60
|
+
// serialises the CPU-heavy build against the API e2e suite), and copying only
|
|
61
|
+
// `check.mjs` installs a file whose very first import resolves to nothing —
|
|
62
|
+
// the project's `check` then dies with ERR_MODULE_NOT_FOUND before running a
|
|
63
|
+
// single step. Deriving it from the imports (not from "every .mjs in the
|
|
64
|
+
// directory") keeps the next sibling free of changes here while making sure a
|
|
65
|
+
// stray file in the asset dir never claims a path in the project's scripts/.
|
|
66
|
+
const copies = resolveCopySet(assetPath);
|
|
67
|
+
// Decide EVERY member before writing ANY of them — see the atomicity note in
|
|
68
|
+
// the doc block above.
|
|
69
|
+
const plans = copies.map((copy) => planCopy(projectRoot, copy));
|
|
70
|
+
const blocked = plans.filter((p) => p.action === 'skip');
|
|
71
|
+
if (blocked.length > 0) {
|
|
72
|
+
// One entry for the whole set: the set is what could not be updated, and
|
|
73
|
+
// naming only the blocking member would suggest the others did land.
|
|
74
|
+
const names = blocked.map((p) => p.rel).join(', ');
|
|
75
|
+
changed.push(`${copies.map((c) => c.rel).join(' + ')} (skipped: uncommitted changes in ${names} — commit or discard them, then re-run)`);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
for (const plan of plans) {
|
|
79
|
+
if (plan.action === 'up-to-date') {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
writeCopy(projectRoot, plan);
|
|
83
|
+
changed.push(plan.rel);
|
|
58
84
|
}
|
|
59
85
|
}
|
|
60
86
|
// 2. Wire package.json: `check` runs the wrapper; the original chain becomes `check:raw`.
|
|
@@ -69,11 +95,57 @@ function healCheckWrapper(projectRoot, assetPath) {
|
|
|
69
95
|
return changed;
|
|
70
96
|
}
|
|
71
97
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
98
|
+
* The wrapper plus the transitive closure of its relative imports.
|
|
99
|
+
*
|
|
100
|
+
* Matches both quote styles: the bundled `.mjs` files are formatted by the
|
|
101
|
+
* consuming project's formatter, not by this repo's, so their quote style is
|
|
102
|
+
* not ours to assume.
|
|
103
|
+
*/
|
|
104
|
+
function resolveCopySet(assetPath) {
|
|
105
|
+
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
|
+
const copies = [{ rel: `${SCRIPTS_DIR}/check.mjs`, source: assetPath }];
|
|
112
|
+
// Keyed by target rel, NOT by source basename: the asset lands as
|
|
113
|
+
// `scripts/check.mjs` whatever it is called, so a `check.mjs` sitting beside a
|
|
114
|
+
// differently-named asset must not claim that same path a second time.
|
|
115
|
+
const claimed = new Set(copies.map((c) => c.rel));
|
|
116
|
+
const queue = [assetPath];
|
|
117
|
+
const visited = new Set();
|
|
118
|
+
while (queue.length > 0) {
|
|
119
|
+
const file = queue.shift();
|
|
120
|
+
if (visited.has(file)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
visited.add(file);
|
|
124
|
+
let source;
|
|
125
|
+
try {
|
|
126
|
+
source = (0, fs_1.readFileSync)(file, 'utf8');
|
|
127
|
+
}
|
|
128
|
+
catch (_a) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
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)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
claimed.add(rel);
|
|
138
|
+
const resolved = (0, path_1.join)(assetDir, name);
|
|
139
|
+
copies.push({ rel, source: resolved });
|
|
140
|
+
queue.push(resolved);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return copies;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* True when the TRACKED `relPath` has uncommitted modifications. Overwriting
|
|
147
|
+
* such a file would destroy work that exists nowhere else. Only meaningful for
|
|
148
|
+
* a tracked path — see `isTracked`.
|
|
77
149
|
*/
|
|
78
150
|
function hasUncommittedChanges(projectRoot, relPath) {
|
|
79
151
|
try {
|
|
@@ -87,3 +159,93 @@ function hasUncommittedChanges(projectRoot, relPath) {
|
|
|
87
159
|
return false;
|
|
88
160
|
}
|
|
89
161
|
}
|
|
162
|
+
/** True when `target` is a symlink (checked without following it). */
|
|
163
|
+
function isSymlink(target) {
|
|
164
|
+
try {
|
|
165
|
+
return (0, fs_1.lstatSync)(target).isSymbolicLink();
|
|
166
|
+
}
|
|
167
|
+
catch (_a) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* True when git tracks `relPath`, i.e. it holds a recoverable copy.
|
|
173
|
+
*
|
|
174
|
+
* An empty `git status --porcelain` alone does NOT establish that: it is also
|
|
175
|
+
* empty for an ignored file, and for a path in a directory git knows nothing
|
|
176
|
+
* about. Those are precisely the cases where an overwrite is unrecoverable.
|
|
177
|
+
*/
|
|
178
|
+
function isTracked(projectRoot, relPath) {
|
|
179
|
+
try {
|
|
180
|
+
(0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'ls-files', '--error-unmatch', '--', relPath], {
|
|
181
|
+
stdio: 'ignore',
|
|
182
|
+
});
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
catch (_a) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** Decide what should happen to one copy target, without touching the disk. */
|
|
190
|
+
function planCopy(projectRoot, copy) {
|
|
191
|
+
const target = (0, path_1.join)(projectRoot, copy.rel);
|
|
192
|
+
const plan = Object.assign(Object.assign({}, copy), { action: 'write', backup: false });
|
|
193
|
+
if (!(0, fs_1.existsSync)(target)) {
|
|
194
|
+
return plan;
|
|
195
|
+
}
|
|
196
|
+
// A symlink here resolves OUTSIDE the project, and git reports the (unchanged)
|
|
197
|
+
// link blob as clean, so the guard below cannot see it. Writing would silently
|
|
198
|
+
// modify a file somewhere else entirely.
|
|
199
|
+
if (isSymlink(target)) {
|
|
200
|
+
plan.action = 'skip';
|
|
201
|
+
return plan;
|
|
202
|
+
}
|
|
203
|
+
if ((0, fs_1.readFileSync)(target, 'utf8') === (0, fs_1.readFileSync)(copy.source, 'utf8')) {
|
|
204
|
+
plan.action = 'up-to-date';
|
|
205
|
+
return plan;
|
|
206
|
+
}
|
|
207
|
+
// A TRACKED file whose working copy diverges carries edits that exist nowhere
|
|
208
|
+
// else — never overwrite it. A tracked-and-clean file is safe to replace
|
|
209
|
+
// (git can restore it). Anything git does not track is not recoverable at
|
|
210
|
+
// all, so it gets a `.bak` instead of a refusal: refusing would be the worse
|
|
211
|
+
// outcome, because the wrapper's OWN previous output is untracked until the
|
|
212
|
+
// user commits it, and a refusal there permanently blocks the update.
|
|
213
|
+
if (isTracked(projectRoot, copy.rel)) {
|
|
214
|
+
if (hasUncommittedChanges(projectRoot, copy.rel)) {
|
|
215
|
+
plan.action = 'skip';
|
|
216
|
+
}
|
|
217
|
+
return plan;
|
|
218
|
+
}
|
|
219
|
+
plan.backup = true;
|
|
220
|
+
return plan;
|
|
221
|
+
}
|
|
222
|
+
/** Write one planned copy, backing up an unversioned target first. */
|
|
223
|
+
function writeCopy(projectRoot, plan) {
|
|
224
|
+
const target = (0, path_1.join)(projectRoot, plan.rel);
|
|
225
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(target), { recursive: true });
|
|
226
|
+
if (plan.backup && (0, fs_1.existsSync)(target)) {
|
|
227
|
+
const backup = `${target}.bak`;
|
|
228
|
+
// Keep the FIRST backup — a later run must not overwrite the original with
|
|
229
|
+
// an already-generated copy.
|
|
230
|
+
if (!(0, fs_1.existsSync)(backup)) {
|
|
231
|
+
(0, fs_1.copyFileSync)(target, backup);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// temp + rename so an interrupted run can never leave a half-written wrapper.
|
|
235
|
+
const tmp = `${target}.lt-tmp-${process.pid}`;
|
|
236
|
+
try {
|
|
237
|
+
(0, fs_1.copyFileSync)(plan.source, tmp);
|
|
238
|
+
(0, fs_1.renameSync)(tmp, target);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
try {
|
|
242
|
+
if ((0, fs_1.existsSync)(tmp)) {
|
|
243
|
+
(0, fs_1.unlinkSync)(tmp);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (_a) {
|
|
247
|
+
/* best effort */
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|