@lenne.tech/cli 1.41.2 → 1.42.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.
@@ -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
+ }
@@ -22,11 +22,26 @@ const fs_utils_1 = require("./fs-utils");
22
22
  * is hoisted too so a sub-project's first-party exemption (e.g.
23
23
  * `@lenne.tech/*`) keeps working in the monorepo — otherwise the
24
24
  * minimum-release-age gate would block freshly published own packages.
25
+ *
26
+ * `auditConfig` is nested (`{ ignoreGhsas: [...], ignoreCves: [...] }`), so it
27
+ * needs a one-level-deeper merge than the flat object fields. It MUST be hoisted:
28
+ * the CI audit job is deploy-blocking, and a settings-only sub-workspace file is
29
+ * deleted after hoisting (see hoistFromSubWorkspaceYaml). Without this the
30
+ * starter's assessed-advisory allowlist is destroyed rather than merely ignored,
31
+ * and the generated project's very first pipeline goes red on an advisory that
32
+ * was already justified upstream.
25
33
  */
26
34
  const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
27
35
  const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
28
- const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS];
36
+ /** Objects whose values are arrays to be unioned, not replaced. */
37
+ const NESTED_ARRAY_FIELDS = ['auditConfig'];
38
+ /** Provenance note written above a hoisted `auditConfig` — see `annotateAuditConfig`. */
39
+ const AUDIT_CONFIG_NOTE = '# Hoisted from the sub-projects by the lt CLI. These advisory suppressions now\n' +
40
+ '# apply to EVERY package in this workspace, not just the one that justified\n' +
41
+ '# 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];
29
43
  const isArrayField = (field) => ARRAY_FIELDS.includes(field);
44
+ const isNestedArrayField = (field) => NESTED_ARRAY_FIELDS.includes(field);
30
45
  /**
31
46
  * Hoist the Corepack `packageManager` pin from sub-projects into the monorepo
32
47
  * root `package.json`, keeping the highest version and stripping the pin from
@@ -169,9 +184,31 @@ function hoistWorkspacePnpmConfig(options) {
169
184
  // Keep allowBuilds (pnpm 11) and onlyBuiltDependencies (pnpm 10) in sync so
170
185
  // the build-script allowlist survives regardless of which key pnpm reads.
171
186
  syncBuildAllowlists(rootWs);
172
- filesystem.write(rootWsPath, (0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false }));
187
+ filesystem.write(rootWsPath, annotateAuditConfig((0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false })));
173
188
  }
174
189
  }
190
+ /**
191
+ * Mark a hoisted `auditConfig` as workspace-wide, in the file itself.
192
+ *
193
+ * `auditConfig.ignoreGhsas` / `.ignoreCves` are not ordinary settings — they
194
+ * SUPPRESS vulnerability findings, and the CI audit job is deploy-blocking.
195
+ * Hoisting changes their blast radius: an advisory a sub-project justified for
196
+ * one dev-only transitive dep now also silences that same advisory when it turns
197
+ * up in a sibling's RUNTIME tree, and pnpm's `auditConfig` has no expiry. That is
198
+ * the correct trade (the alternative — deleting the settings-only sub file
199
+ * unhoisted — destroys the allowlist and reddens the first pipeline), but it must
200
+ * not be invisible.
201
+ *
202
+ * A comment in the YAML is where a reviewer actually looks: it survives in the
203
+ * file, shows up in the `git diff` that introduces it, and needs no plumbing
204
+ * through the void-returning scaffolding call chain.
205
+ */
206
+ function annotateAuditConfig(yaml) {
207
+ if (!/^auditConfig:/m.test(yaml) || yaml.includes(AUDIT_CONFIG_NOTE)) {
208
+ return yaml;
209
+ }
210
+ return yaml.replace(/^auditConfig:/m, `${AUDIT_CONFIG_NOTE}\nauditConfig:`);
211
+ }
175
212
  /**
176
213
  * Compare the versions of two `packageManager` pins (`pnpm@11.13.1+sha512.…`).
177
214
  * Returns >0 if `a` is newer, <0 if older, 0 if equal. Numeric segment-wise
@@ -265,6 +302,26 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
265
302
  const subArr = Array.isArray(subValue) ? subValue : [];
266
303
  return Array.from(new Set([...rootArr, ...subArr])).sort((a, b) => a.localeCompare(b));
267
304
  }
305
+ // Nested (`auditConfig.ignoreGhsas` / `.ignoreCves`): union each inner array
306
+ // instead of letting the sub-project's object replace the root's. A plain
307
+ // key-by-key merge would drop every advisory the root had already justified.
308
+ if (isNestedArrayField(field)) {
309
+ const asObj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
310
+ const rootObj = asObj(rootValue);
311
+ const subObj = asObj(subValue);
312
+ const merged = Object.assign({}, rootObj);
313
+ for (const [key, value] of Object.entries(subObj)) {
314
+ if (Array.isArray(value) || Array.isArray(merged[key])) {
315
+ const a = Array.isArray(merged[key]) ? merged[key] : [];
316
+ const b = Array.isArray(value) ? value : [];
317
+ merged[key] = Array.from(new Set([...a, ...b])).sort((x, y) => x.localeCompare(y));
318
+ }
319
+ else {
320
+ merged[key] = value;
321
+ }
322
+ }
323
+ return Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
324
+ }
268
325
  const rootObj = rootValue && typeof rootValue === 'object' && !Array.isArray(rootValue)
269
326
  ? rootValue
270
327
  : {};