@lenne.tech/cli 1.42.0 → 1.44.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 -0
- package/build/commands/fullstack/add-app.js +7 -0
- package/build/commands/fullstack/init.js +32 -3
- package/build/commands/fullstack/update.js +24 -1
- package/build/commands/git/reset.js +1 -1
- package/build/commands/git/update.js +2 -2
- package/build/extensions/frontend-helper.js +19 -0
- package/build/extensions/git.js +23 -2
- package/build/extensions/server.js +28 -3
- package/build/lib/adopt-upstream-build-allowlist.js +140 -0
- package/build/lib/fail-run.js +38 -0
- package/build/lib/heal-check-wrapper.js +189 -27
- package/build/lib/hoist-workspace-pnpm-config.js +173 -5
- package/build/lib/strip-vendor-schema-augmentation.js +213 -0
- package/build/lib/vendor-claude-md.js +15 -0
- package/build/templates/check/build-test-gate.mjs +107 -0
- package/build/templates/check/check.mjs +422 -52
- package/docs/VENDOR-MODE-WORKFLOW.md +35 -0
- package/docs/commands.md +9 -0
- package/package.json +8 -4
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.adoptUpstreamBuildAllowlist = adoptUpstreamBuildAllowlist;
|
|
4
|
+
const js_yaml_1 = require("js-yaml");
|
|
5
|
+
const fs_utils_1 = require("./fs-utils");
|
|
6
|
+
const hoist_workspace_pnpm_config_1 = require("./hoist-workspace-pnpm-config");
|
|
7
|
+
/**
|
|
8
|
+
* Carry the framework's build-script allowlist into a project that has just
|
|
9
|
+
* vendored it.
|
|
10
|
+
*
|
|
11
|
+
* `allowBuilds` decides which packages may run install scripts. pnpm 11 does not
|
|
12
|
+
* treat an unlisted one as "deny" — it ABORTS the install with
|
|
13
|
+
* `ERR_PNPM_IGNORED_BUILDS` and writes a `set this to true or false` placeholder
|
|
14
|
+
* into the workspace file. So a single missing entry is not a hardening gap; it
|
|
15
|
+
* is a project that cannot be installed at all, on the very first
|
|
16
|
+
* `lt fullstack init`, before anyone has written a line of code.
|
|
17
|
+
*
|
|
18
|
+
* Vendoring is exactly where that gap opens. The conversion resolves the core's
|
|
19
|
+
* import closure into DIRECT dependencies — `import('bullmq')` in
|
|
20
|
+
* core-cron-jobs.service.ts becomes a real `bullmq` dep, which pulls
|
|
21
|
+
* `msgpackr` → `msgpackr-extract`, a package with an install script. nest-server
|
|
22
|
+
* knows about it and lists it. The project does not, and cannot: nest-server's
|
|
23
|
+
* `pnpm-workspace.yaml` is not part of its npm tarball — `files` ships `dist`,
|
|
24
|
+
* `src`, `bin` and the docs, never a repo-root config file — so nothing
|
|
25
|
+
* downstream can read it. Until now the only bridge was a
|
|
26
|
+
* human copying entries into nest-server-starter by hand — and that bridge has
|
|
27
|
+
* already been observed to rot: `@scarf/scarf` sat at `true` in the starter while
|
|
28
|
+
* the framework denied it, for long enough that the drift shipped.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately additive. A key the project has already decided is left exactly as
|
|
31
|
+
* it is, including an explicit `false`: the project is the more specific context,
|
|
32
|
+
* and silently flipping its decision to match the framework would be a worse bug
|
|
33
|
+
* than the one this fixes. Only genuinely absent keys are taken over — together
|
|
34
|
+
* with the comment that explains them, because an entry like
|
|
35
|
+
* `'msgpackr-extract': false` reads as dead weight without it and gets deleted by
|
|
36
|
+
* the next person who runs `pnpm why`.
|
|
37
|
+
*
|
|
38
|
+
* @returns the keys actually adopted, for the caller to report.
|
|
39
|
+
*/
|
|
40
|
+
function adoptUpstreamBuildAllowlist(options) {
|
|
41
|
+
var _a;
|
|
42
|
+
const { dest, filesystem, upstreamWorkspaceYaml } = options;
|
|
43
|
+
if (!upstreamWorkspaceYaml)
|
|
44
|
+
return [];
|
|
45
|
+
const upstream = parseYamlObject(upstreamWorkspaceYaml);
|
|
46
|
+
const upstreamAllow = asStringBoolMap(upstream === null || upstream === void 0 ? void 0 : upstream.allowBuilds);
|
|
47
|
+
if (Object.keys(upstreamAllow).length === 0)
|
|
48
|
+
return [];
|
|
49
|
+
// Never write through a symlinked project: with `--api-link` it points at the
|
|
50
|
+
// user's own nest-server-starter checkout, and this would edit their repo. The
|
|
51
|
+
// same guard already protects `hoistWorkspacePnpmConfig` and
|
|
52
|
+
// `removeNestedLockfiles`; these two libs were the odd ones out.
|
|
53
|
+
if ((0, fs_utils_1.isSymlink)(dest))
|
|
54
|
+
return [];
|
|
55
|
+
const destPath = `${dest}/pnpm-workspace.yaml`;
|
|
56
|
+
// No file to extend means no pnpm settings of the project's own. Writing one
|
|
57
|
+
// here would invent a workspace root the scaffolding did not ask for, so the
|
|
58
|
+
// absence is respected rather than filled in.
|
|
59
|
+
if (!filesystem.exists(destPath))
|
|
60
|
+
return [];
|
|
61
|
+
const destRaw = (_a = filesystem.read(destPath)) !== null && _a !== void 0 ? _a : '';
|
|
62
|
+
const destWs = parseYamlObject(destRaw);
|
|
63
|
+
if (!destWs)
|
|
64
|
+
return [];
|
|
65
|
+
// The project's map is read RAW, not through `asStringBoolMap`. Narrowing it to
|
|
66
|
+
// booleans first was a live deny-bypass with no attacker involved: js-yaml 4
|
|
67
|
+
// uses the YAML 1.2 core schema, so `esbuild: no` and `esbuild: off` parse as
|
|
68
|
+
// STRINGS. A maintainer writing `no` to mean "deny" was read as "no opinion",
|
|
69
|
+
// upstream's `true` was adopted, and the original entry was dropped from the
|
|
70
|
+
// rewritten map as well. Same for `'false'`, for an empty value (null), and for
|
|
71
|
+
// pnpm's own `set this to true or false` placeholder — all five verified.
|
|
72
|
+
//
|
|
73
|
+
// `Object.create(null)` and `hasOwnProperty.call`: with a plain object,
|
|
74
|
+
// `'constructor' in map` is true via the prototype, so eight real package names
|
|
75
|
+
// ('constructor', 'toString', 'valueOf', …) would be treated as already decided
|
|
76
|
+
// and silently never adopted — the ERR_PNPM_IGNORED_BUILDS abort this whole
|
|
77
|
+
// function exists to prevent.
|
|
78
|
+
const merged = Object.assign(Object.create(null), asRawMap(destWs.allowBuilds));
|
|
79
|
+
const adopted = [];
|
|
80
|
+
for (const [pkg, value] of Object.entries(upstreamAllow)) {
|
|
81
|
+
// ANY existing key is a decision, whatever shape YAML gave it.
|
|
82
|
+
if (Object.prototype.hasOwnProperty.call(merged, pkg))
|
|
83
|
+
continue;
|
|
84
|
+
merged[pkg] = value;
|
|
85
|
+
adopted.push(pkg);
|
|
86
|
+
}
|
|
87
|
+
if (adopted.length === 0)
|
|
88
|
+
return [];
|
|
89
|
+
destWs.allowBuilds = Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
|
|
90
|
+
// The project's own annotations win where both files comment the same key; the
|
|
91
|
+
// upstream ones fill in only for the keys just adopted, which by definition the
|
|
92
|
+
// project had nothing to say about.
|
|
93
|
+
const comments = (0, hoist_workspace_pnpm_config_1.extractKeyComments)(destRaw);
|
|
94
|
+
for (const [key, block] of (0, hoist_workspace_pnpm_config_1.extractKeyComments)(upstreamWorkspaceYaml)) {
|
|
95
|
+
if (!comments.has(key))
|
|
96
|
+
comments.set(key, block);
|
|
97
|
+
}
|
|
98
|
+
filesystem.write(destPath, (0, hoist_workspace_pnpm_config_1.reattachKeyComments)((0, js_yaml_1.dump)(destWs, { lineWidth: -1, sortKeys: false }), comments));
|
|
99
|
+
return adopted;
|
|
100
|
+
}
|
|
101
|
+
/** The value as a plain key/value map, or an empty one — no narrowing of values. */
|
|
102
|
+
function asRawMap(value) {
|
|
103
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
104
|
+
return {};
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Narrow an unknown value to a `{ pkg: boolean }` map, dropping other shapes.
|
|
109
|
+
*
|
|
110
|
+
* Used for the UPSTREAM side only. There, dropping a non-boolean is right — we
|
|
111
|
+
* adopt only decisions we understand. On the DEST side it is the opposite: see
|
|
112
|
+
* the comment at the merge above.
|
|
113
|
+
*/
|
|
114
|
+
function asStringBoolMap(value) {
|
|
115
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
116
|
+
return {};
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const [key, v] of Object.entries(value)) {
|
|
119
|
+
if (typeof v === 'boolean')
|
|
120
|
+
out[key] = v;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/** Parse YAML into a plain object, or null on empty/malformed/non-object input. */
|
|
125
|
+
function parseYamlObject(raw) {
|
|
126
|
+
if (!raw)
|
|
127
|
+
return null;
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = (0, js_yaml_1.load)(raw);
|
|
131
|
+
}
|
|
132
|
+
catch (_a) {
|
|
133
|
+
// Malformed upstream YAML must not take the conversion down with it — the
|
|
134
|
+
// vendored project is still usable, it just does not inherit the allowlist.
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
138
|
+
return null;
|
|
139
|
+
return parsed;
|
|
140
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.failRun = failRun;
|
|
4
|
+
/**
|
|
5
|
+
* Mark the current command run as failed.
|
|
6
|
+
*
|
|
7
|
+
* A gluegun command signals failure by printing and returning, and a bare
|
|
8
|
+
* `return` leaves the process at exit code 0. So a scaffold that died halfway
|
|
9
|
+
* reported SUCCESS to every caller that checks `$?` — a CI job, a wrapper
|
|
10
|
+
* script, an agent. That is not theoretical: a `lt fullstack init` whose
|
|
11
|
+
* `pnpm install` aborted on a native build script printed a red spinner and
|
|
12
|
+
* still exited 0, and the half-built workspace was only noticed later, by hand.
|
|
13
|
+
*
|
|
14
|
+
* Call it immediately before every `return` on an error path:
|
|
15
|
+
*
|
|
16
|
+
* failRun(toolbox);
|
|
17
|
+
* return;
|
|
18
|
+
*
|
|
19
|
+
* **`process.exitCode`, not `process.exit()`** — the latter can truncate
|
|
20
|
+
* buffered output, including the spinner's own failure message, which is the one
|
|
21
|
+
* line the operator actually needs.
|
|
22
|
+
*
|
|
23
|
+
* **Guarded by `fromGluegunMenu`**, like the CLI's other exit-code call sites
|
|
24
|
+
* (`dev test`, `dev tunnel`, `tools ocr`, `workspace-integration`): inside the
|
|
25
|
+
* interactive `lt` menu a command is one step of a longer session, and failing
|
|
26
|
+
* the whole session because one step errored is the same over-reach in reverse.
|
|
27
|
+
*
|
|
28
|
+
* Shared rather than redeclared per command: `fullstack init` delegates to
|
|
29
|
+
* `add-api` / `add-app` inside an existing workspace, so all three have to agree
|
|
30
|
+
* on the contract or the exit code depends on which directory the user happened
|
|
31
|
+
* to be standing in — which was exactly the state before this helper existed.
|
|
32
|
+
*/
|
|
33
|
+
function failRun(toolbox) {
|
|
34
|
+
var _a, _b;
|
|
35
|
+
if (!((_b = (_a = toolbox.parameters) === null || _a === void 0 ? void 0 : _a.options) === null || _b === void 0 ? void 0 : _b.fromGluegunMenu)) {
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractKeyComments = extractKeyComments;
|
|
4
|
+
exports.reattachKeyComments = reattachKeyComments;
|
|
3
5
|
exports.hoistPackageManager = hoistPackageManager;
|
|
4
6
|
exports.hoistWorkspacePnpmConfig = hoistWorkspacePnpmConfig;
|
|
5
7
|
const js_yaml_1 = require("js-yaml");
|
|
@@ -35,11 +37,162 @@ const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
|
|
|
35
37
|
const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
|
|
36
38
|
/** Objects whose values are arrays to be unioned, not replaced. */
|
|
37
39
|
const NESTED_ARRAY_FIELDS = ['auditConfig'];
|
|
40
|
+
/** The union of all three, in declaration order. Declared here, with its inputs,
|
|
41
|
+
* because the comment-carrying helpers below default their `fields` parameter to it. */
|
|
42
|
+
const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS, ...NESTED_ARRAY_FIELDS];
|
|
43
|
+
/**
|
|
44
|
+
* Separator for the composite map key.
|
|
45
|
+
*
|
|
46
|
+
* `\0` rather than a space, because a YAML mapping key may legally contain
|
|
47
|
+
* spaces — `overrides` selectors like `minimatch@>=5.0.0 <10.2.6` do — and a
|
|
48
|
+
* space would let two different (field, key) pairs collide on one entry,
|
|
49
|
+
* silently attaching one entry's reasoning to another's. Written as an escape
|
|
50
|
+
* rather than a literal control character: a raw NUL in the source makes git
|
|
51
|
+
* treat this file as binary, which costs every future reviewer the diff.
|
|
52
|
+
*/
|
|
53
|
+
const KEY_SEPARATOR = '\0';
|
|
54
|
+
/**
|
|
55
|
+
* Control characters that must never survive into an emitted YAML comment.
|
|
56
|
+
*
|
|
57
|
+
* Everything below U+0020 except TAB (U+0009) and LF (U+000A) — CR included,
|
|
58
|
+
* deliberately: it is the one that reads as whitespace and parses as a line
|
|
59
|
+
* break. LF cannot appear here (the harvest splits on it) and TAB is harmless.
|
|
60
|
+
*/
|
|
61
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F]/;
|
|
62
|
+
const commentKey = (field, key) => `${field}${KEY_SEPARATOR}${key}`;
|
|
63
|
+
/**
|
|
64
|
+
* Comment blocks attached to the entries of each top-level mapping in `raw`.
|
|
65
|
+
*
|
|
66
|
+
* Only contiguous `#` lines DIRECTLY above an entry are taken, and a blank line
|
|
67
|
+
* ends the block — a comment separated from a key by an empty line belongs to the
|
|
68
|
+
* section, not to that key, and re-attaching it would silently move a section
|
|
69
|
+
* header onto whichever entry happened to come first.
|
|
70
|
+
*/
|
|
71
|
+
function extractKeyComments(raw, fields = WORKSPACE_SCOPED_PNPM_FIELDS) {
|
|
72
|
+
const out = new Map();
|
|
73
|
+
if (!raw)
|
|
74
|
+
return out;
|
|
75
|
+
const lines = raw.split('\n');
|
|
76
|
+
let field = null;
|
|
77
|
+
let fieldIndent = 0;
|
|
78
|
+
let pending = [];
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const topLevel = /^([A-Za-z_][\w-]*):\s*$/.exec(line);
|
|
81
|
+
if (topLevel) {
|
|
82
|
+
field = fields.includes(topLevel[1]) ? topLevel[1] : null;
|
|
83
|
+
fieldIndent = 0;
|
|
84
|
+
pending = [];
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (field === null)
|
|
88
|
+
continue;
|
|
89
|
+
if (/^\s*$/.test(line)) {
|
|
90
|
+
pending = [];
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const indent = line.search(/\S/);
|
|
94
|
+
// Back at column 0 → the mapping is over (a new top-level key or a list item).
|
|
95
|
+
if (indent === 0) {
|
|
96
|
+
field = null;
|
|
97
|
+
pending = [];
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (/^\s*#/.test(line)) {
|
|
101
|
+
// A bare CR is NOT a line break to `String.split('\n')` but IS one to every
|
|
102
|
+
// YAML parser. So a comment containing one is re-emitted verbatim, and
|
|
103
|
+
// everything after the CR becomes real YAML at a column of its author's
|
|
104
|
+
// choosing. Verified against pnpm 11: a comment carrying
|
|
105
|
+
// `\r left-pad: 9.9.9` installs as a workspace-wide `overrides` entry —
|
|
106
|
+
// an arbitrary version force in every generated project — while the line
|
|
107
|
+
// still renders as an ordinary comment in editors and diffs.
|
|
108
|
+
//
|
|
109
|
+
// Dropping the whole block is the right response rather than sanitising it:
|
|
110
|
+
// a rationale nobody can read is worth less than the risk of guessing what
|
|
111
|
+
// the author meant.
|
|
112
|
+
if (CONTROL_CHARS.test(line)) {
|
|
113
|
+
pending = [];
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
pending.push(line.trimStart());
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const entry = /^\s*((?:'[^']*')|(?:"[^"]*")|(?:[^\s:#][^:]*?))\s*:/.exec(line);
|
|
120
|
+
if (!entry) {
|
|
121
|
+
pending = [];
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// Nested deeper than the first entry level (e.g. `auditConfig.ignoreGhsas`
|
|
125
|
+
// items) — the block belongs to the inner key, which this pass does not carry.
|
|
126
|
+
if (fieldIndent === 0)
|
|
127
|
+
fieldIndent = indent;
|
|
128
|
+
if (indent === fieldIndent && pending.length) {
|
|
129
|
+
out.set(commentKey(field, unquoteYamlKey(entry[1])), pending.join('\n'));
|
|
130
|
+
}
|
|
131
|
+
pending = [];
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Put the harvested comment blocks back above their keys in dumped YAML.
|
|
137
|
+
*
|
|
138
|
+
* A key whose comment is already present is left alone, so re-running the hoist
|
|
139
|
+
* over an already-annotated file is idempotent rather than stuttering.
|
|
140
|
+
*/
|
|
141
|
+
function reattachKeyComments(yaml, comments, fields = WORKSPACE_SCOPED_PNPM_FIELDS) {
|
|
142
|
+
var _a;
|
|
143
|
+
if (comments.size === 0)
|
|
144
|
+
return yaml;
|
|
145
|
+
const lines = yaml.split('\n');
|
|
146
|
+
const out = [];
|
|
147
|
+
let field = null;
|
|
148
|
+
let fieldIndent = 0;
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
const topLevel = /^([A-Za-z_][\w-]*):\s*$/.exec(line);
|
|
151
|
+
if (topLevel) {
|
|
152
|
+
field = fields.includes(topLevel[1]) ? topLevel[1] : null;
|
|
153
|
+
fieldIndent = 0;
|
|
154
|
+
out.push(line);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (field !== null && !/^\s*$/.test(line)) {
|
|
158
|
+
const indent = line.search(/\S/);
|
|
159
|
+
if (indent === 0) {
|
|
160
|
+
field = null;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const entry = /^\s*((?:'[^']*')|(?:"[^"]*")|(?:[^\s:#][^:]*?))\s*:/.exec(line);
|
|
164
|
+
if (entry) {
|
|
165
|
+
if (fieldIndent === 0)
|
|
166
|
+
fieldIndent = indent;
|
|
167
|
+
if (indent === fieldIndent) {
|
|
168
|
+
const block = comments.get(commentKey(field, unquoteYamlKey(entry[1])));
|
|
169
|
+
// `out[out.length - 1]`, not `.at(-1)`: this project's tsconfig lib
|
|
170
|
+
// predates ES2022.
|
|
171
|
+
const already = ((_a = out[out.length - 1]) !== null && _a !== void 0 ? _a : '').trim().startsWith('#');
|
|
172
|
+
// Second gate on purpose: harvest is one source of blocks today, and a
|
|
173
|
+
// control character reaching the emitted file is the whole exploit.
|
|
174
|
+
if (block && !already && !CONTROL_CHARS.test(block)) {
|
|
175
|
+
const pad = ' '.repeat(indent);
|
|
176
|
+
out.push(...block.split('\n').map((l) => `${pad}${l}`));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
out.push(line);
|
|
183
|
+
}
|
|
184
|
+
return out.join('\n');
|
|
185
|
+
}
|
|
186
|
+
/** `'msgpackr-extract'` / `"foo"` / `foo` all denote the same mapping key. */
|
|
187
|
+
function unquoteYamlKey(raw) {
|
|
188
|
+
const trimmed = raw.trim();
|
|
189
|
+
const quoted = /^(['"])([\s\S]*)\1$/.exec(trimmed);
|
|
190
|
+
return quoted ? quoted[2] : trimmed;
|
|
191
|
+
}
|
|
38
192
|
/** Provenance note written above a hoisted `auditConfig` — see `annotateAuditConfig`. */
|
|
39
193
|
const AUDIT_CONFIG_NOTE = '# Hoisted from the sub-projects by the lt CLI. These advisory suppressions now\n' +
|
|
40
194
|
'# apply to EVERY package in this workspace, not just the one that justified\n' +
|
|
41
195
|
'# them — review before adding, and drop entries once the advisory is fixed.';
|
|
42
|
-
const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS, ...NESTED_ARRAY_FIELDS];
|
|
43
196
|
const isArrayField = (field) => ARRAY_FIELDS.includes(field);
|
|
44
197
|
const isNestedArrayField = (field) => NESTED_ARRAY_FIELDS.includes(field);
|
|
45
198
|
/**
|
|
@@ -157,13 +310,18 @@ function hoistPackageManager(options) {
|
|
|
157
310
|
* @param options.subProjects Sub-project dirs relative to projectDir
|
|
158
311
|
*/
|
|
159
312
|
function hoistWorkspacePnpmConfig(options) {
|
|
160
|
-
var _a;
|
|
313
|
+
var _a, _b;
|
|
161
314
|
const { filesystem, projectDir, subProjects } = options;
|
|
162
315
|
const rootWsPath = `${projectDir}/pnpm-workspace.yaml`;
|
|
163
316
|
// The root pnpm-workspace.yaml is the destination. It normally exists (the
|
|
164
317
|
// lt-monorepo clone ships one declaring `packages:`); start from it so
|
|
165
318
|
// `packages:` and any root-owned settings are preserved.
|
|
166
319
|
const rootWs = (_a = readYaml(filesystem, rootWsPath)) !== null && _a !== void 0 ? _a : {};
|
|
320
|
+
// Why the reasons are harvested rather than regenerated: they are prose written
|
|
321
|
+
// by whoever added the entry, and no rule can reconstruct them. The root's own
|
|
322
|
+
// comments are collected FIRST so that where two sources annotate the same key,
|
|
323
|
+
// the root's wording wins — it is the file a maintainer of THIS workspace edits.
|
|
324
|
+
const comments = extractKeyComments((_b = filesystem.read(rootWsPath)) !== null && _b !== void 0 ? _b : '');
|
|
167
325
|
let rootChanged = false;
|
|
168
326
|
for (const subDir of subProjects) {
|
|
169
327
|
const subPath = `${projectDir}/${subDir}`;
|
|
@@ -176,7 +334,7 @@ function hoistWorkspacePnpmConfig(options) {
|
|
|
176
334
|
if (hoistFromSubPackageJson({ filesystem, rootWs, subPath })) {
|
|
177
335
|
rootChanged = true;
|
|
178
336
|
}
|
|
179
|
-
if (hoistFromSubWorkspaceYaml({ filesystem, rootWs, subPath })) {
|
|
337
|
+
if (hoistFromSubWorkspaceYaml({ comments, filesystem, rootWs, subPath })) {
|
|
180
338
|
rootChanged = true;
|
|
181
339
|
}
|
|
182
340
|
}
|
|
@@ -184,7 +342,8 @@ function hoistWorkspacePnpmConfig(options) {
|
|
|
184
342
|
// Keep allowBuilds (pnpm 11) and onlyBuiltDependencies (pnpm 10) in sync so
|
|
185
343
|
// the build-script allowlist survives regardless of which key pnpm reads.
|
|
186
344
|
syncBuildAllowlists(rootWs);
|
|
187
|
-
|
|
345
|
+
const dumped = (0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false });
|
|
346
|
+
filesystem.write(rootWsPath, annotateAuditConfig(reattachKeyComments(dumped, comments)));
|
|
188
347
|
}
|
|
189
348
|
}
|
|
190
349
|
/**
|
|
@@ -264,13 +423,22 @@ function hoistFromSubPackageJson(options) {
|
|
|
264
423
|
}
|
|
265
424
|
/** Source 2: the sub-project's pnpm-workspace.yaml. */
|
|
266
425
|
function hoistFromSubWorkspaceYaml(options) {
|
|
267
|
-
|
|
426
|
+
var _a;
|
|
427
|
+
const { comments, filesystem, rootWs, subPath } = options;
|
|
268
428
|
const subWsPath = `${subPath}/pnpm-workspace.yaml`;
|
|
269
429
|
if (!filesystem.exists(subWsPath))
|
|
270
430
|
return false;
|
|
431
|
+
const raw = (_a = filesystem.read(subWsPath)) !== null && _a !== void 0 ? _a : '';
|
|
271
432
|
const ws = readYaml(filesystem, subWsPath);
|
|
272
433
|
if (!ws)
|
|
273
434
|
return false;
|
|
435
|
+
// Harvest BEFORE hoisting: this file is about to be deleted (or stripped of
|
|
436
|
+
// exactly these keys), and with it the only copy of the reasoning. An entry
|
|
437
|
+
// already annotated by the root keeps the root's wording.
|
|
438
|
+
for (const [key, block] of extractKeyComments(raw)) {
|
|
439
|
+
if (!comments.has(key))
|
|
440
|
+
comments.set(key, block);
|
|
441
|
+
}
|
|
274
442
|
if (!hoistFields(rootWs, ws))
|
|
275
443
|
return false;
|
|
276
444
|
// A settings-only file (no `packages:`) exists solely to carry these
|