@lenne.tech/cli 1.41.3 → 1.43.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/deployment/create.js +18 -1
- package/build/commands/dev/vscode.js +173 -0
- 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 +17 -0
- 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/dev-test-session.js +119 -7
- package/build/lib/dev-ticket.js +17 -2
- package/build/lib/fail-run.js +38 -0
- package/build/lib/heal-vendor-migrate-store.js +285 -0
- package/build/lib/hoist-workspace-pnpm-config.js +230 -5
- package/build/lib/strip-vendor-schema-augmentation.js +213 -0
- package/build/lib/vendor-claude-md.js +15 -0
- package/build/lib/vscode-settings.js +351 -0
- package/docs/LT-ECOSYSTEM-GUIDE.md +1 -1
- package/docs/VENDOR-MODE-WORKFLOW.md +35 -0
- package/docs/commands.md +140 -1
- package/package.json +36 -17
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.healVendorMigrateStore = healVendorMigrateStore;
|
|
37
|
+
const child_process_1 = require("child_process");
|
|
38
|
+
const fs_1 = require("fs");
|
|
39
|
+
const path_1 = require("path");
|
|
40
|
+
const ts = __importStar(require("typescript"));
|
|
41
|
+
/**
|
|
42
|
+
* Idempotently repair a vendor-mode project's migration store
|
|
43
|
+
* (`migrations-utils/migrate.js`).
|
|
44
|
+
*
|
|
45
|
+
* ## The defect this heals
|
|
46
|
+
*
|
|
47
|
+
* The store is generated ONCE, during `convertCloneToVendored()`. Projects that
|
|
48
|
+
* were converted before the template learned its lesson keep the old file
|
|
49
|
+
* forever: `migrations-utils/` is project scaffolding, not `src/core/`, so
|
|
50
|
+
* neither the core updater nor any other update path ever touches it again.
|
|
51
|
+
*
|
|
52
|
+
* The old variant registers the ts-node bootstrap UNCONDITIONALLY:
|
|
53
|
+
*
|
|
54
|
+
* ```js
|
|
55
|
+
* require('./ts-compiler'); // -> require('ts-node')
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* `ts-node` is a devDependency that the production Dockerfile prunes with
|
|
59
|
+
* `pnpm install --prod`, while the image needs no transpiler at all (everything
|
|
60
|
+
* next to the store is already compiled). So every deployed container dies with
|
|
61
|
+
* `Cannot find module 'ts-node'` before applying a single migration.
|
|
62
|
+
*
|
|
63
|
+
* It stays invisible because `docker-entrypoint.sh` degrades a migration failure
|
|
64
|
+
* to a warning on purpose — a bad migration must not crash-loop the container and
|
|
65
|
+
* leave the orchestrator serving a stale build. The container reports healthy,
|
|
66
|
+
* nothing is migrated, and nobody notices.
|
|
67
|
+
*
|
|
68
|
+
* ## Why the detection is inverted — the expensive lesson
|
|
69
|
+
*
|
|
70
|
+
* This function replaces the file WHOLESALE, and the replacement is not
|
|
71
|
+
* behaviour-neutral: the bundled template hardcodes the collection name
|
|
72
|
+
* (`'migrations'`) and takes its URI from `./mongo-uri`. A project that used a
|
|
73
|
+
* different collection therefore gets an EMPTY migration ledger — and the next
|
|
74
|
+
* `migrate:up` re-runs every historical migration against the live database. A
|
|
75
|
+
* project that never had `./mongo-uri` crashes outright.
|
|
76
|
+
*
|
|
77
|
+
* The first implementation asked "can I SEE a guard?" and treated the answer
|
|
78
|
+
* "no" as proof that none exists. It recognised exactly two shapes — a
|
|
79
|
+
* `require.resolve` probe and a `try {` — so a perfectly production-safe
|
|
80
|
+
* `if (!fs.existsSync(compiled)) require('./ts-compiler')` read as broken and
|
|
81
|
+
* was destroyed, together with its collection name.
|
|
82
|
+
*
|
|
83
|
+
* So the question is inverted: heal ONLY when the hazard is positively proven,
|
|
84
|
+
* i.e. the require sits as a TOP-LEVEL, unconditional statement — the one shape
|
|
85
|
+
* that genuinely cannot survive a pruned image. Every other shape (inside `try`,
|
|
86
|
+
* `if`, a function, a ternary, a block) is by construction conditional, hence
|
|
87
|
+
* the project's own solution, and is left alone. "I did not recognise a guard"
|
|
88
|
+
* is no longer evidence that there is none.
|
|
89
|
+
*
|
|
90
|
+
* Detection runs on the TypeScript AST, not on regex-stripped text. A regex
|
|
91
|
+
* "lexer" has no string/template/regex-literal state, so a `/*` or `//` inside a
|
|
92
|
+
* literal earlier in the file silently erased the guard and triggered the very
|
|
93
|
+
* overwrite this function must avoid. `lib/strip-comments.ts` solves the comment
|
|
94
|
+
* half properly (TS scanner) and would have been the right reuse; the AST solves
|
|
95
|
+
* comments AND nesting in one step, and recognises a backtick require for free.
|
|
96
|
+
*
|
|
97
|
+
* ## Recoverability
|
|
98
|
+
*
|
|
99
|
+
* An overwrite is only acceptable when it can be undone. `git status --porcelain`
|
|
100
|
+
* returning nothing does NOT mean "committed" — it also means untracked-and-
|
|
101
|
+
* ignored, or not a git repo at all, i.e. exactly the cases where nothing can be
|
|
102
|
+
* recovered. The guard therefore establishes tracked-ness directly
|
|
103
|
+
* (`git ls-files --error-unmatch`) and writes a `.bak` whenever it cannot prove
|
|
104
|
+
* git has a copy. A file with UNCOMMITTED modifications is never overwritten —
|
|
105
|
+
* that would destroy work which exists nowhere else — and is reported as skipped.
|
|
106
|
+
*
|
|
107
|
+
* @param apiDir Absolute path to the api project (the directory holding `src/core`).
|
|
108
|
+
* @param assetPath Absolute path to the bundled `templates/vendor-scripts/migrate-store.js`.
|
|
109
|
+
* @returns Changed paths relative to `apiDir`; empty when nothing needed healing.
|
|
110
|
+
*/
|
|
111
|
+
function healVendorMigrateStore(apiDir, assetPath) {
|
|
112
|
+
const changed = [];
|
|
113
|
+
// Vendor mode only. In npm mode the store requires the compiled
|
|
114
|
+
// `@lenne.tech/nest-server` package and never needs a transpiler.
|
|
115
|
+
if (!(0, fs_1.existsSync)((0, path_1.join)(apiDir, 'src', 'core', 'VENDOR.md'))) {
|
|
116
|
+
return changed;
|
|
117
|
+
}
|
|
118
|
+
const rel = 'migrations-utils/migrate.js';
|
|
119
|
+
const storePath = (0, path_1.join)(apiDir, 'migrations-utils', 'migrate.js');
|
|
120
|
+
if (!(0, fs_1.existsSync)(storePath) || !(0, fs_1.existsSync)(assetPath)) {
|
|
121
|
+
return changed;
|
|
122
|
+
}
|
|
123
|
+
// Never write THROUGH a symlink: the target may live anywhere, and the caller
|
|
124
|
+
// asked us to repair a store, not to overwrite whatever it points at.
|
|
125
|
+
if (isSymbolicLink(storePath)) {
|
|
126
|
+
changed.push(`${rel} (skipped: is a symlink — repair the file it points at instead)`);
|
|
127
|
+
return changed;
|
|
128
|
+
}
|
|
129
|
+
let current;
|
|
130
|
+
try {
|
|
131
|
+
current = (0, fs_1.readFileSync)(storePath, 'utf8');
|
|
132
|
+
}
|
|
133
|
+
catch (_a) {
|
|
134
|
+
return changed;
|
|
135
|
+
}
|
|
136
|
+
if (!hasTopLevelTsCompilerRequire(current)) {
|
|
137
|
+
return changed;
|
|
138
|
+
}
|
|
139
|
+
const recoverability = gitRecoverability(apiDir, rel);
|
|
140
|
+
if (recoverability === 'dirty') {
|
|
141
|
+
changed.push(`${rel} (skipped: uncommitted changes — commit or discard them, then re-run)`);
|
|
142
|
+
return changed;
|
|
143
|
+
}
|
|
144
|
+
let template;
|
|
145
|
+
try {
|
|
146
|
+
template = (0, fs_1.readFileSync)(assetPath, 'utf8');
|
|
147
|
+
}
|
|
148
|
+
catch (_b) {
|
|
149
|
+
return changed;
|
|
150
|
+
}
|
|
151
|
+
// No git copy to fall back on (untracked, ignored, or not a repo at all), so
|
|
152
|
+
// leave one on disk before touching the file.
|
|
153
|
+
let note = '';
|
|
154
|
+
if (recoverability === 'unknown') {
|
|
155
|
+
const backupPath = `${storePath}.bak`;
|
|
156
|
+
try {
|
|
157
|
+
if (!(0, fs_1.existsSync)(backupPath)) {
|
|
158
|
+
(0, fs_1.copyFileSync)(storePath, backupPath);
|
|
159
|
+
}
|
|
160
|
+
note = ` — previous version saved to ${rel}.bak`;
|
|
161
|
+
}
|
|
162
|
+
catch (_c) {
|
|
163
|
+
changed.push(`${rel} (skipped: git has no copy and the .bak could not be written)`);
|
|
164
|
+
return changed;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Atomic: a crash between write and rename leaves the original intact rather
|
|
168
|
+
// than a truncated store the migrate CLI would then fail to parse.
|
|
169
|
+
if (!writeAtomic(storePath, template)) {
|
|
170
|
+
changed.push(`${rel} (skipped: write failed)`);
|
|
171
|
+
return changed;
|
|
172
|
+
}
|
|
173
|
+
changed.push(`${rel} (migrations never ran in deployed containers — see the file header)${note}`);
|
|
174
|
+
return changed;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether git holds a recoverable copy of `relPath`.
|
|
178
|
+
*
|
|
179
|
+
* - `recoverable` — tracked and unmodified: an overwrite is undoable via git.
|
|
180
|
+
* - `dirty` — tracked with uncommitted edits: must not be overwritten.
|
|
181
|
+
* - `unknown` — untracked, ignored, no repo, or no `git` on PATH. Git can
|
|
182
|
+
* recover nothing here, so the caller must back up itself.
|
|
183
|
+
*
|
|
184
|
+
* Deliberately does NOT infer "committed" from empty `status --porcelain`
|
|
185
|
+
* output: an ignored or untracked file is equally silent there, and treating
|
|
186
|
+
* that silence as safety is what made the overwrite unrecoverable.
|
|
187
|
+
*/
|
|
188
|
+
function gitRecoverability(projectRoot, relPath) {
|
|
189
|
+
try {
|
|
190
|
+
// Throws unless the path is TRACKED — the property we actually depend on.
|
|
191
|
+
(0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'ls-files', '--error-unmatch', '--', relPath], {
|
|
192
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (_a) {
|
|
196
|
+
return 'unknown';
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const out = (0, child_process_1.execFileSync)('git', ['-C', projectRoot, 'status', '--porcelain', '--', relPath], {
|
|
200
|
+
encoding: 'utf8',
|
|
201
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
202
|
+
});
|
|
203
|
+
return out.trim().length > 0 ? 'dirty' : 'recoverable';
|
|
204
|
+
}
|
|
205
|
+
catch (_b) {
|
|
206
|
+
return 'unknown';
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* True when the file requires the ts-node bootstrap as a TOP-LEVEL, unconditional
|
|
211
|
+
* statement — the only shape that provably breaks in a production image where
|
|
212
|
+
* ts-node has been pruned.
|
|
213
|
+
*
|
|
214
|
+
* Anything nested is conditional by construction and therefore the project's own
|
|
215
|
+
* (working) solution — both of these are left alone, as is any other guard shape
|
|
216
|
+
* someone invents:
|
|
217
|
+
*
|
|
218
|
+
* ```js
|
|
219
|
+
* try { require.resolve(`${HELPER}.js`) } catch { require('./ts-compiler') }
|
|
220
|
+
* if (!fs.existsSync(compiled)) { require('./ts-compiler') }
|
|
221
|
+
* ```
|
|
222
|
+
*
|
|
223
|
+
* Uses the AST rather than text matching, so comments, string literals, template
|
|
224
|
+
* literals and regex literals cannot fake — or hide — a match.
|
|
225
|
+
*/
|
|
226
|
+
function hasTopLevelTsCompilerRequire(source) {
|
|
227
|
+
let sourceFile;
|
|
228
|
+
try {
|
|
229
|
+
sourceFile = ts.createSourceFile('migrate.js', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
|
230
|
+
}
|
|
231
|
+
catch (_a) {
|
|
232
|
+
// Unparseable: we cannot prove the hazard, so we must not act.
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
return sourceFile.statements.some((statement) => {
|
|
236
|
+
if (!ts.isExpressionStatement(statement)) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
return isTsCompilerRequireCall(statement.expression);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/** True when `path` is a symlink (never follows it). */
|
|
243
|
+
function isSymbolicLink(path) {
|
|
244
|
+
try {
|
|
245
|
+
return (0, fs_1.lstatSync)(path).isSymbolicLink();
|
|
246
|
+
}
|
|
247
|
+
catch (_a) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/** True for `require('./ts-compiler')` — single string or backtick argument. */
|
|
252
|
+
function isTsCompilerRequireCall(node) {
|
|
253
|
+
if (!ts.isCallExpression(node)) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
if (!ts.isIdentifier(node.expression) || node.expression.text !== 'require') {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
if (node.arguments.length !== 1) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
const arg = node.arguments[0];
|
|
263
|
+
const isLiteral = ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg);
|
|
264
|
+
return isLiteral && arg.text === './ts-compiler';
|
|
265
|
+
}
|
|
266
|
+
/** Write via temp file + rename so a crash cannot leave a truncated store. */
|
|
267
|
+
function writeAtomic(target, content) {
|
|
268
|
+
const tmp = `${target}.lt-tmp`;
|
|
269
|
+
try {
|
|
270
|
+
(0, fs_1.writeFileSync)(tmp, content);
|
|
271
|
+
(0, fs_1.renameSync)(tmp, target);
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
catch (_a) {
|
|
275
|
+
try {
|
|
276
|
+
if ((0, fs_1.existsSync)(tmp)) {
|
|
277
|
+
(0, fs_1.unlinkSync)(tmp);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (_b) {
|
|
281
|
+
/* best effort */
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -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");
|
|
@@ -22,11 +24,177 @@ const fs_utils_1 = require("./fs-utils");
|
|
|
22
24
|
* is hoisted too so a sub-project's first-party exemption (e.g.
|
|
23
25
|
* `@lenne.tech/*`) keeps working in the monorepo — otherwise the
|
|
24
26
|
* minimum-release-age gate would block freshly published own packages.
|
|
27
|
+
*
|
|
28
|
+
* `auditConfig` is nested (`{ ignoreGhsas: [...], ignoreCves: [...] }`), so it
|
|
29
|
+
* needs a one-level-deeper merge than the flat object fields. It MUST be hoisted:
|
|
30
|
+
* the CI audit job is deploy-blocking, and a settings-only sub-workspace file is
|
|
31
|
+
* deleted after hoisting (see hoistFromSubWorkspaceYaml). Without this the
|
|
32
|
+
* starter's assessed-advisory allowlist is destroyed rather than merely ignored,
|
|
33
|
+
* and the generated project's very first pipeline goes red on an advisory that
|
|
34
|
+
* was already justified upstream.
|
|
25
35
|
*/
|
|
26
36
|
const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
|
|
27
37
|
const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
|
|
28
|
-
|
|
38
|
+
/** Objects whose values are arrays to be unioned, not replaced. */
|
|
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
|
+
}
|
|
192
|
+
/** Provenance note written above a hoisted `auditConfig` — see `annotateAuditConfig`. */
|
|
193
|
+
const AUDIT_CONFIG_NOTE = '# Hoisted from the sub-projects by the lt CLI. These advisory suppressions now\n' +
|
|
194
|
+
'# apply to EVERY package in this workspace, not just the one that justified\n' +
|
|
195
|
+
'# them — review before adding, and drop entries once the advisory is fixed.';
|
|
29
196
|
const isArrayField = (field) => ARRAY_FIELDS.includes(field);
|
|
197
|
+
const isNestedArrayField = (field) => NESTED_ARRAY_FIELDS.includes(field);
|
|
30
198
|
/**
|
|
31
199
|
* Hoist the Corepack `packageManager` pin from sub-projects into the monorepo
|
|
32
200
|
* root `package.json`, keeping the highest version and stripping the pin from
|
|
@@ -142,13 +310,18 @@ function hoistPackageManager(options) {
|
|
|
142
310
|
* @param options.subProjects Sub-project dirs relative to projectDir
|
|
143
311
|
*/
|
|
144
312
|
function hoistWorkspacePnpmConfig(options) {
|
|
145
|
-
var _a;
|
|
313
|
+
var _a, _b;
|
|
146
314
|
const { filesystem, projectDir, subProjects } = options;
|
|
147
315
|
const rootWsPath = `${projectDir}/pnpm-workspace.yaml`;
|
|
148
316
|
// The root pnpm-workspace.yaml is the destination. It normally exists (the
|
|
149
317
|
// lt-monorepo clone ships one declaring `packages:`); start from it so
|
|
150
318
|
// `packages:` and any root-owned settings are preserved.
|
|
151
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 : '');
|
|
152
325
|
let rootChanged = false;
|
|
153
326
|
for (const subDir of subProjects) {
|
|
154
327
|
const subPath = `${projectDir}/${subDir}`;
|
|
@@ -161,7 +334,7 @@ function hoistWorkspacePnpmConfig(options) {
|
|
|
161
334
|
if (hoistFromSubPackageJson({ filesystem, rootWs, subPath })) {
|
|
162
335
|
rootChanged = true;
|
|
163
336
|
}
|
|
164
|
-
if (hoistFromSubWorkspaceYaml({ filesystem, rootWs, subPath })) {
|
|
337
|
+
if (hoistFromSubWorkspaceYaml({ comments, filesystem, rootWs, subPath })) {
|
|
165
338
|
rootChanged = true;
|
|
166
339
|
}
|
|
167
340
|
}
|
|
@@ -169,8 +342,31 @@ function hoistWorkspacePnpmConfig(options) {
|
|
|
169
342
|
// Keep allowBuilds (pnpm 11) and onlyBuiltDependencies (pnpm 10) in sync so
|
|
170
343
|
// the build-script allowlist survives regardless of which key pnpm reads.
|
|
171
344
|
syncBuildAllowlists(rootWs);
|
|
172
|
-
|
|
345
|
+
const dumped = (0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false });
|
|
346
|
+
filesystem.write(rootWsPath, annotateAuditConfig(reattachKeyComments(dumped, comments)));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Mark a hoisted `auditConfig` as workspace-wide, in the file itself.
|
|
351
|
+
*
|
|
352
|
+
* `auditConfig.ignoreGhsas` / `.ignoreCves` are not ordinary settings — they
|
|
353
|
+
* SUPPRESS vulnerability findings, and the CI audit job is deploy-blocking.
|
|
354
|
+
* Hoisting changes their blast radius: an advisory a sub-project justified for
|
|
355
|
+
* one dev-only transitive dep now also silences that same advisory when it turns
|
|
356
|
+
* up in a sibling's RUNTIME tree, and pnpm's `auditConfig` has no expiry. That is
|
|
357
|
+
* the correct trade (the alternative — deleting the settings-only sub file
|
|
358
|
+
* unhoisted — destroys the allowlist and reddens the first pipeline), but it must
|
|
359
|
+
* not be invisible.
|
|
360
|
+
*
|
|
361
|
+
* A comment in the YAML is where a reviewer actually looks: it survives in the
|
|
362
|
+
* file, shows up in the `git diff` that introduces it, and needs no plumbing
|
|
363
|
+
* through the void-returning scaffolding call chain.
|
|
364
|
+
*/
|
|
365
|
+
function annotateAuditConfig(yaml) {
|
|
366
|
+
if (!/^auditConfig:/m.test(yaml) || yaml.includes(AUDIT_CONFIG_NOTE)) {
|
|
367
|
+
return yaml;
|
|
173
368
|
}
|
|
369
|
+
return yaml.replace(/^auditConfig:/m, `${AUDIT_CONFIG_NOTE}\nauditConfig:`);
|
|
174
370
|
}
|
|
175
371
|
/**
|
|
176
372
|
* Compare the versions of two `packageManager` pins (`pnpm@11.13.1+sha512.…`).
|
|
@@ -227,13 +423,22 @@ function hoistFromSubPackageJson(options) {
|
|
|
227
423
|
}
|
|
228
424
|
/** Source 2: the sub-project's pnpm-workspace.yaml. */
|
|
229
425
|
function hoistFromSubWorkspaceYaml(options) {
|
|
230
|
-
|
|
426
|
+
var _a;
|
|
427
|
+
const { comments, filesystem, rootWs, subPath } = options;
|
|
231
428
|
const subWsPath = `${subPath}/pnpm-workspace.yaml`;
|
|
232
429
|
if (!filesystem.exists(subWsPath))
|
|
233
430
|
return false;
|
|
431
|
+
const raw = (_a = filesystem.read(subWsPath)) !== null && _a !== void 0 ? _a : '';
|
|
234
432
|
const ws = readYaml(filesystem, subWsPath);
|
|
235
433
|
if (!ws)
|
|
236
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
|
+
}
|
|
237
442
|
if (!hoistFields(rootWs, ws))
|
|
238
443
|
return false;
|
|
239
444
|
// A settings-only file (no `packages:`) exists solely to carry these
|
|
@@ -265,6 +470,26 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
|
|
|
265
470
|
const subArr = Array.isArray(subValue) ? subValue : [];
|
|
266
471
|
return Array.from(new Set([...rootArr, ...subArr])).sort((a, b) => a.localeCompare(b));
|
|
267
472
|
}
|
|
473
|
+
// Nested (`auditConfig.ignoreGhsas` / `.ignoreCves`): union each inner array
|
|
474
|
+
// instead of letting the sub-project's object replace the root's. A plain
|
|
475
|
+
// key-by-key merge would drop every advisory the root had already justified.
|
|
476
|
+
if (isNestedArrayField(field)) {
|
|
477
|
+
const asObj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
478
|
+
const rootObj = asObj(rootValue);
|
|
479
|
+
const subObj = asObj(subValue);
|
|
480
|
+
const merged = Object.assign({}, rootObj);
|
|
481
|
+
for (const [key, value] of Object.entries(subObj)) {
|
|
482
|
+
if (Array.isArray(value) || Array.isArray(merged[key])) {
|
|
483
|
+
const a = Array.isArray(merged[key]) ? merged[key] : [];
|
|
484
|
+
const b = Array.isArray(value) ? value : [];
|
|
485
|
+
merged[key] = Array.from(new Set([...a, ...b])).sort((x, y) => x.localeCompare(y));
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
merged[key] = value;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
|
|
492
|
+
}
|
|
268
493
|
const rootObj = rootValue && typeof rootValue === 'object' && !Array.isArray(rootValue)
|
|
269
494
|
? rootValue
|
|
270
495
|
: {};
|