@lenne.tech/cli 1.32.0 → 1.33.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/fullstack/update.js +13 -0
- package/build/extensions/frontend-helper.js +5 -20
- package/build/extensions/server.js +6 -28
- package/build/lib/check-freshness-hooks.js +53 -0
- package/build/lib/dev-patches.js +5 -2
- package/build/lib/heal-check-wrapper.js +59 -0
- package/build/lib/hoist-workspace-pnpm-config.js +105 -92
- package/build/templates/check/check.mjs +512 -0
- package/package.json +5 -3
|
@@ -12,6 +12,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
12
12
|
const path_1 = require("path");
|
|
13
13
|
const framework_detection_1 = require("../../lib/framework-detection");
|
|
14
14
|
const frontend_framework_detection_1 = require("../../lib/frontend-framework-detection");
|
|
15
|
+
const heal_check_wrapper_1 = require("../../lib/heal-check-wrapper");
|
|
15
16
|
const vendor_claude_md_1 = require("../../lib/vendor-claude-md");
|
|
16
17
|
/**
|
|
17
18
|
* Update a fullstack workspace — mode-aware.
|
|
@@ -185,6 +186,18 @@ const NewCommand = {
|
|
|
185
186
|
info(` ${changedPath}`);
|
|
186
187
|
}
|
|
187
188
|
}
|
|
189
|
+
// ── Self-heal: install/refresh the report-driven `check` wrapper ──────
|
|
190
|
+
//
|
|
191
|
+
// `lt fullstack init` ships `scripts/check.mjs` via the template clone, but
|
|
192
|
+
// pre-existing projects predate it. This idempotently installs the bundled
|
|
193
|
+
// wrapper (and rewrites the root `check`/`check:raw`) so a migrated project
|
|
194
|
+
// gets the quiet, report-driven check too. No-op once already wired.
|
|
195
|
+
const checkAsset = (0, path_1.join)(__dirname, '..', '..', 'templates', 'check', 'check.mjs');
|
|
196
|
+
const changedCheck = (0, heal_check_wrapper_1.healCheckWrapper)(cwd, checkAsset);
|
|
197
|
+
if (changedCheck.length > 0) {
|
|
198
|
+
info('');
|
|
199
|
+
success(` Installed/updated the check wrapper: ${changedCheck.join(', ')}`);
|
|
200
|
+
}
|
|
188
201
|
info('');
|
|
189
202
|
info(colors.bold('For a comprehensive update of everything, use:'));
|
|
190
203
|
info('');
|
|
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.FrontendHelper = void 0;
|
|
13
|
+
const check_freshness_hooks_1 = require("../lib/check-freshness-hooks");
|
|
13
14
|
const markdown_table_1 = require("../lib/markdown-table");
|
|
14
15
|
const vendor_claude_md_1 = require("../lib/vendor-claude-md");
|
|
15
16
|
/**
|
|
@@ -316,7 +317,6 @@ class FrontendHelper {
|
|
|
316
317
|
*/
|
|
317
318
|
convertAppToNpmMode(options) {
|
|
318
319
|
return __awaiter(this, void 0, void 0, function* () {
|
|
319
|
-
var _a;
|
|
320
320
|
const { dest, targetVersion } = options;
|
|
321
321
|
const { filesystem } = this.toolbox;
|
|
322
322
|
const path = require('node:path');
|
|
@@ -375,13 +375,7 @@ class FrontendHelper {
|
|
|
375
375
|
// Remove vendor-specific scripts
|
|
376
376
|
if (pkg.scripts && typeof pkg.scripts === 'object') {
|
|
377
377
|
const scripts = pkg.scripts;
|
|
378
|
-
|
|
379
|
-
// Unhook freshness from check/check:fix/check:naf
|
|
380
|
-
for (const scriptName of ['check', 'check:fix', 'check:naf']) {
|
|
381
|
-
if ((_a = scripts[scriptName]) === null || _a === void 0 ? void 0 : _a.includes('check:vendor-freshness')) {
|
|
382
|
-
scripts[scriptName] = scripts[scriptName].replace(/pnpm run check:vendor-freshness && /g, '');
|
|
383
|
-
}
|
|
384
|
-
}
|
|
378
|
+
(0, check_freshness_hooks_1.unhookCheckFreshness)(scripts);
|
|
385
379
|
}
|
|
386
380
|
filesystem.write(pkgPath, pkg, { jsonIndent: 2 });
|
|
387
381
|
}
|
|
@@ -576,18 +570,9 @@ class FrontendHelper {
|
|
|
576
570
|
'setTimeout(function(){process.exit(0)},5000)',
|
|
577
571
|
'"',
|
|
578
572
|
].join('');
|
|
579
|
-
// Hook freshness into check scripts
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (!existing)
|
|
583
|
-
return;
|
|
584
|
-
if (existing.includes('check:vendor-freshness'))
|
|
585
|
-
return;
|
|
586
|
-
scripts[scriptName] = `pnpm run check:vendor-freshness && ${existing}`;
|
|
587
|
-
};
|
|
588
|
-
hookFreshness('check');
|
|
589
|
-
hookFreshness('check:fix');
|
|
590
|
-
hookFreshness('check:naf');
|
|
573
|
+
// Hook freshness into the check scripts (targets check:raw, never the
|
|
574
|
+
// check.mjs wrapper). See the shared lib helper.
|
|
575
|
+
(0, check_freshness_hooks_1.hookCheckFreshness)(scripts);
|
|
591
576
|
}
|
|
592
577
|
// Sort dependency maps alphabetically so merged-in entries
|
|
593
578
|
// (e.g. upstream `@nuxt/kit`) end up in the expected position
|
|
@@ -46,6 +46,7 @@ exports.Server = void 0;
|
|
|
46
46
|
const crypto = __importStar(require("crypto"));
|
|
47
47
|
const path_1 = require("path");
|
|
48
48
|
const ts = __importStar(require("typescript"));
|
|
49
|
+
const check_freshness_hooks_1 = require("../lib/check-freshness-hooks");
|
|
49
50
|
const markdown_table_1 = require("../lib/markdown-table");
|
|
50
51
|
const vendor_claude_md_1 = require("../lib/vendor-claude-md");
|
|
51
52
|
/**
|
|
@@ -1458,26 +1459,9 @@ class Server {
|
|
|
1458
1459
|
'"',
|
|
1459
1460
|
].join('');
|
|
1460
1461
|
// Hook vendor-freshness as the first step of check / check:fix /
|
|
1461
|
-
// check:naf
|
|
1462
|
-
//
|
|
1463
|
-
|
|
1464
|
-
const existing = scripts[scriptName];
|
|
1465
|
-
if (!existing)
|
|
1466
|
-
return;
|
|
1467
|
-
if (existing.includes('check:vendor-freshness'))
|
|
1468
|
-
return;
|
|
1469
|
-
const installPrefix = 'pnpm install && ';
|
|
1470
|
-
if (existing.startsWith(installPrefix)) {
|
|
1471
|
-
scripts[scriptName] =
|
|
1472
|
-
`${installPrefix}pnpm run check:vendor-freshness && ${existing.slice(installPrefix.length)}`;
|
|
1473
|
-
}
|
|
1474
|
-
else {
|
|
1475
|
-
scripts[scriptName] = `pnpm run check:vendor-freshness && ${existing}`;
|
|
1476
|
-
}
|
|
1477
|
-
};
|
|
1478
|
-
hookFreshness('check');
|
|
1479
|
-
hookFreshness('check:fix');
|
|
1480
|
-
hookFreshness('check:naf');
|
|
1462
|
+
// check:naf (non-blocking; surfaces a warning at the top of the log).
|
|
1463
|
+
// Targets check:raw — never the check.mjs wrapper. See lib helper.
|
|
1464
|
+
(0, check_freshness_hooks_1.hookCheckFreshness)(scripts);
|
|
1481
1465
|
}
|
|
1482
1466
|
filesystem.write(pkgPath, pkg, { jsonIndent: 2 });
|
|
1483
1467
|
}
|
|
@@ -2218,17 +2202,11 @@ class Server {
|
|
|
2218
2202
|
// (they'll come back via node_modules when nest-server is installed)
|
|
2219
2203
|
// We only remove deps that are ALSO in nest-server's package.json.
|
|
2220
2204
|
// For safety, we don't remove any dep the consumer might use directly.
|
|
2221
|
-
// Remove vendor-specific scripts
|
|
2205
|
+
// Remove vendor-specific scripts + unhook vendor-freshness (see lib helper).
|
|
2222
2206
|
const scripts = pkg.scripts || {};
|
|
2223
|
-
delete scripts['check:vendor-freshness'];
|
|
2224
2207
|
delete scripts['vendor:sync'];
|
|
2225
2208
|
delete scripts['vendor:propose-upstream'];
|
|
2226
|
-
|
|
2227
|
-
for (const key of ['check', 'check:fix', 'check:naf']) {
|
|
2228
|
-
if (scripts[key] && typeof scripts[key] === 'string') {
|
|
2229
|
-
scripts[key] = scripts[key].replace(/pnpm run check:vendor-freshness && /g, '');
|
|
2230
|
-
}
|
|
2231
|
-
}
|
|
2209
|
+
(0, check_freshness_hooks_1.unhookCheckFreshness)(scripts);
|
|
2232
2210
|
// Restore migrate scripts to npm-mode paths
|
|
2233
2211
|
const migrateCompiler = 'ts:./node_modules/@lenne.tech/nest-server/dist/core/modules/migrate/helpers/ts-compiler.js';
|
|
2234
2212
|
const migrateStore = '--store ./migrations-utils/migrate.js --migrations-dir ./migrations';
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Helpers for (un)hooking the vendor-freshness step in a project's `check`
|
|
4
|
+
* scripts during vendor-mode conversion. Extracted so the logic is unit-tested
|
|
5
|
+
* (server.ts + frontend-helper.ts call these instead of inline closures).
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.hookCheckFreshness = hookCheckFreshness;
|
|
9
|
+
exports.unhookCheckFreshness = unhookCheckFreshness;
|
|
10
|
+
const WRAPPER_HINT = 'check.mjs';
|
|
11
|
+
const FRESHNESS_STEP = 'pnpm run check:vendor-freshness';
|
|
12
|
+
const INSTALL_PREFIX = 'pnpm install && ';
|
|
13
|
+
/**
|
|
14
|
+
* Prepend the `check:vendor-freshness` step to the relevant check scripts.
|
|
15
|
+
*
|
|
16
|
+
* - Skips a script that already includes the step (idempotent) or that IS the
|
|
17
|
+
* `node scripts/check.mjs` wrapper (the wrapper only orchestrates the real
|
|
18
|
+
* chain — vendor-freshness belongs in that chain).
|
|
19
|
+
* - Targets `check:raw` instead of `check` when the project runs the wrapper,
|
|
20
|
+
* so freshness becomes a step the wrapper executes.
|
|
21
|
+
* - Preserves a leading `pnpm install && ` so install stays first.
|
|
22
|
+
*
|
|
23
|
+
* Assumes the caller has already defined `scripts['check:vendor-freshness']`.
|
|
24
|
+
* Mutates `scripts` in place.
|
|
25
|
+
*/
|
|
26
|
+
function hookCheckFreshness(scripts) {
|
|
27
|
+
const hook = (name) => {
|
|
28
|
+
const existing = scripts[name];
|
|
29
|
+
if (!existing || existing.includes('check:vendor-freshness') || existing.includes(WRAPPER_HINT)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
scripts[name] = existing.startsWith(INSTALL_PREFIX)
|
|
33
|
+
? `${INSTALL_PREFIX}${FRESHNESS_STEP} && ${existing.slice(INSTALL_PREFIX.length)}`
|
|
34
|
+
: `${FRESHNESS_STEP} && ${existing}`;
|
|
35
|
+
};
|
|
36
|
+
// Prefer the raw chain when the project runs the check.mjs wrapper.
|
|
37
|
+
hook(scripts['check:raw'] ? 'check:raw' : 'check');
|
|
38
|
+
hook('check:fix');
|
|
39
|
+
hook('check:naf');
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Remove the vendor-freshness step (and its script) from a project's check
|
|
43
|
+
* scripts — the inverse of {@link hookCheckFreshness}, used when converting a
|
|
44
|
+
* project back to npm mode. Mutates `scripts` in place.
|
|
45
|
+
*/
|
|
46
|
+
function unhookCheckFreshness(scripts) {
|
|
47
|
+
delete scripts['check:vendor-freshness'];
|
|
48
|
+
for (const name of ['check', 'check:raw', 'check:fix', 'check:naf']) {
|
|
49
|
+
if (typeof scripts[name] === 'string') {
|
|
50
|
+
scripts[name] = scripts[name].replace(/pnpm run check:vendor-freshness && /g, '');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
package/build/lib/dev-patches.js
CHANGED
|
@@ -209,13 +209,16 @@ function patchPlaywrightConfig(file) {
|
|
|
209
209
|
"let __ltDevEnvFile = '';",
|
|
210
210
|
'for (let __ltDevDir = process.cwd(), __i = 0; __i < 6; __i++) {',
|
|
211
211
|
" const __candidate = __ltDevResolve(__ltDevDir, '.lt-dev/.env');",
|
|
212
|
-
' if (__ltDevExists(__candidate)) {
|
|
212
|
+
' if (__ltDevExists(__candidate)) {',
|
|
213
|
+
' __ltDevEnvFile = __candidate;',
|
|
214
|
+
' break;',
|
|
215
|
+
' }',
|
|
213
216
|
' const __parent = __ltDevDirname(__ltDevDir);',
|
|
214
217
|
' if (__parent === __ltDevDir) break;',
|
|
215
218
|
' __ltDevDir = __parent;',
|
|
216
219
|
'}',
|
|
217
220
|
'if (__ltDevEnvFile) {',
|
|
218
|
-
|
|
221
|
+
" for (const __ln of __ltDevRead(__ltDevEnvFile, 'utf8').split(/\\r?\\n/)) {",
|
|
219
222
|
' const __m = __ln.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);',
|
|
220
223
|
' if (__m && process.env[__m[1]] === undefined) process.env[__m[1]] = __m[2];',
|
|
221
224
|
' }',
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.healCheckWrapper = healCheckWrapper;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
/** Marker value for the report-driven check wrapper. */
|
|
7
|
+
const WRAPPER = 'node scripts/check.mjs';
|
|
8
|
+
/**
|
|
9
|
+
* Idempotently install the report-driven `check.mjs` wrapper into a project.
|
|
10
|
+
*
|
|
11
|
+
* Copies the bundled canonical wrapper to `<root>/scripts/check.mjs` and rewrites
|
|
12
|
+
* the root `package.json` so that `check` runs the wrapper while the original
|
|
13
|
+
* chain is preserved as `check:raw`. A no-op once already wired (so it is safe to
|
|
14
|
+
* run on every `lt fullstack update`).
|
|
15
|
+
*
|
|
16
|
+
* `lt fullstack init` already ships the wrapper via the template clone; this is
|
|
17
|
+
* the MIGRATION path that brings it into pre-existing projects.
|
|
18
|
+
*
|
|
19
|
+
* @param projectRoot Absolute path to the (workspace) project root.
|
|
20
|
+
* @param assetPath Absolute path to the bundled canonical `check.mjs`.
|
|
21
|
+
* @returns The list of changed file paths (relative to `projectRoot`); empty when nothing changed.
|
|
22
|
+
*/
|
|
23
|
+
function healCheckWrapper(projectRoot, assetPath) {
|
|
24
|
+
const changed = [];
|
|
25
|
+
const pkgPath = (0, path_1.join)(projectRoot, 'package.json');
|
|
26
|
+
if (!(0, fs_1.existsSync)(pkgPath) || !(0, fs_1.existsSync)(assetPath)) {
|
|
27
|
+
return changed;
|
|
28
|
+
}
|
|
29
|
+
let pkg;
|
|
30
|
+
try {
|
|
31
|
+
pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, 'utf8'));
|
|
32
|
+
}
|
|
33
|
+
catch (_a) {
|
|
34
|
+
return changed;
|
|
35
|
+
}
|
|
36
|
+
const scripts = pkg.scripts;
|
|
37
|
+
// Only touch projects that actually define a `check` script.
|
|
38
|
+
if (!scripts || typeof scripts.check !== 'string') {
|
|
39
|
+
return changed;
|
|
40
|
+
}
|
|
41
|
+
// 1. Ensure scripts/check.mjs exists and matches the bundled canonical version.
|
|
42
|
+
const targetScript = (0, path_1.join)(projectRoot, 'scripts', 'check.mjs');
|
|
43
|
+
const bundled = (0, fs_1.readFileSync)(assetPath, 'utf8');
|
|
44
|
+
if (!(0, fs_1.existsSync)(targetScript) || (0, fs_1.readFileSync)(targetScript, 'utf8') !== bundled) {
|
|
45
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(targetScript), { recursive: true });
|
|
46
|
+
(0, fs_1.copyFileSync)(assetPath, targetScript);
|
|
47
|
+
changed.push('scripts/check.mjs');
|
|
48
|
+
}
|
|
49
|
+
// 2. Wire package.json: `check` runs the wrapper; the original chain becomes `check:raw`.
|
|
50
|
+
if (scripts.check !== WRAPPER) {
|
|
51
|
+
if (!scripts['check:raw']) {
|
|
52
|
+
scripts['check:raw'] = scripts.check;
|
|
53
|
+
}
|
|
54
|
+
scripts.check = WRAPPER;
|
|
55
|
+
(0, fs_1.writeFileSync)(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
56
|
+
changed.push('package.json');
|
|
57
|
+
}
|
|
58
|
+
return changed;
|
|
59
|
+
}
|
|
@@ -15,40 +15,41 @@ const js_yaml_1 = require("js-yaml");
|
|
|
15
15
|
* overrides defined only in projects/api/package.json never reach the
|
|
16
16
|
* install resolver. Hoisting them to the root fixes both the warning
|
|
17
17
|
* and the actual dependency-resolution behavior.
|
|
18
|
+
*
|
|
19
|
+
* Object-valued fields (`overrides`, `allowBuilds`) merge key-by-key;
|
|
20
|
+
* array-valued fields union + dedupe + sort. `minimumReleaseAgeExclude`
|
|
21
|
+
* is hoisted too so a sub-project's first-party exemption (e.g.
|
|
22
|
+
* `@lenne.tech/*`) keeps working in the monorepo — otherwise the
|
|
23
|
+
* minimum-release-age gate would block freshly published own packages.
|
|
18
24
|
*/
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
* usually carries BOTH for cross-version compatibility, but we must not
|
|
24
|
-
* rely on the array twin always being present: we normalise `allowBuilds`
|
|
25
|
-
* back into `onlyBuiltDependencies` before hoisting (see
|
|
26
|
-
* `normalizeAllowBuilds`) so the build-allowlist survives into the pnpm-10
|
|
27
|
-
* monorepo root even when the file only carries the pnpm-11 object form.
|
|
28
|
-
*/
|
|
29
|
-
const PNPM11_BUILD_KEY = 'allowBuilds';
|
|
25
|
+
const OBJECT_FIELDS = ['overrides', 'allowBuilds'];
|
|
26
|
+
const ARRAY_FIELDS = ['onlyBuiltDependencies', 'ignoredOptionalDependencies', 'minimumReleaseAgeExclude'];
|
|
27
|
+
const WORKSPACE_SCOPED_PNPM_FIELDS = [...OBJECT_FIELDS, ...ARRAY_FIELDS];
|
|
28
|
+
const isArrayField = (field) => ARRAY_FIELDS.includes(field);
|
|
30
29
|
/**
|
|
31
|
-
* Hoist workspace-scoped pnpm config from sub-projects into the
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
30
|
+
* Hoist workspace-scoped pnpm config from sub-projects into the monorepo
|
|
31
|
+
* root `pnpm-workspace.yaml`. After this runs, sub-project pnpm config
|
|
32
|
+
* (package.json#pnpm or a settings-only pnpm-workspace.yaml) is gone, and
|
|
33
|
+
* the root pnpm-workspace.yaml carries the merged union next to `packages:`.
|
|
34
|
+
*
|
|
35
|
+
* Why pnpm-workspace.yaml and not package.json#pnpm: the monorepo root
|
|
36
|
+
* pins pnpm 11 (via `packageManager`), and pnpm 11 SILENTLY IGNORES the
|
|
37
|
+
* `pnpm` block in package.json — overrides/build-allowlists/etc. declared
|
|
38
|
+
* there never take effect, regressing `pnpm audit` and the minimum-release
|
|
39
|
+
* -age exemptions. pnpm-workspace.yaml is the pnpm-recommended home and is
|
|
40
|
+
* read by both pnpm 10 and 11.
|
|
36
41
|
*
|
|
37
42
|
* Two sources are read per sub-project, because the two starters store
|
|
38
43
|
* their pnpm config differently:
|
|
39
44
|
*
|
|
40
|
-
* 1. `<sub>/package.json` `pnpm` block — nest-server-starter, and
|
|
41
|
-
*
|
|
42
|
-
* 2. `<sub>/pnpm-workspace.yaml` — nuxt-base-template
|
|
43
|
-
*
|
|
44
|
-
* package.json,
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* the
|
|
48
|
-
* conflicts with the monorepo's own pnpm-workspace.yaml. We hoist
|
|
49
|
-
* its fields into the root package.json (the lt-monorepo root pins
|
|
50
|
-
* pnpm@10 via `packageManager`, where package.json#pnpm IS honored)
|
|
51
|
-
* and remove the now-redundant nested file.
|
|
45
|
+
* 1. `<sub>/package.json` `pnpm` block — nest-server-starter, and any
|
|
46
|
+
* template that has not migrated to the pnpm-11 layout yet.
|
|
47
|
+
* 2. `<sub>/pnpm-workspace.yaml` — nuxt-base-template (pnpm-11 layout).
|
|
48
|
+
* Inside a monorepo that nested file would (a) not be hoisted if we
|
|
49
|
+
* only read package.json, regressing the CVE overrides, and (b)
|
|
50
|
+
* declare a nested workspace root that conflicts with the monorepo's
|
|
51
|
+
* own pnpm-workspace.yaml. We hoist its fields into the root and
|
|
52
|
+
* remove the now-redundant nested file.
|
|
52
53
|
*
|
|
53
54
|
* Symlinked sub-projects are skipped entirely: in `--frontend-link` /
|
|
54
55
|
* `--api-link` mode `projects/app` (or `projects/api`) points at the
|
|
@@ -64,14 +65,11 @@ const PNPM11_BUILD_KEY = 'allowBuilds';
|
|
|
64
65
|
function hoistWorkspacePnpmConfig(options) {
|
|
65
66
|
var _a;
|
|
66
67
|
const { filesystem, projectDir, subProjects } = options;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
return;
|
|
73
|
-
(_a = rootPkg.pnpm) !== null && _a !== void 0 ? _a : (rootPkg.pnpm = {});
|
|
74
|
-
const rootPnpm = rootPkg.pnpm;
|
|
68
|
+
const rootWsPath = `${projectDir}/pnpm-workspace.yaml`;
|
|
69
|
+
// The root pnpm-workspace.yaml is the destination. It normally exists (the
|
|
70
|
+
// lt-monorepo clone ships one declaring `packages:`); start from it so
|
|
71
|
+
// `packages:` and any root-owned settings are preserved.
|
|
72
|
+
const rootWs = (_a = readYaml(filesystem, rootWsPath)) !== null && _a !== void 0 ? _a : {};
|
|
75
73
|
let rootChanged = false;
|
|
76
74
|
for (const subDir of subProjects) {
|
|
77
75
|
const subPath = `${projectDir}/${subDir}`;
|
|
@@ -81,27 +79,30 @@ function hoistWorkspacePnpmConfig(options) {
|
|
|
81
79
|
// checkout in link mode.
|
|
82
80
|
if (isSymlink(subPath))
|
|
83
81
|
continue;
|
|
84
|
-
if (hoistFromSubPackageJson({ filesystem,
|
|
82
|
+
if (hoistFromSubPackageJson({ filesystem, rootWs, subPath })) {
|
|
85
83
|
rootChanged = true;
|
|
86
84
|
}
|
|
87
|
-
if (hoistFromSubWorkspaceYaml({ filesystem,
|
|
85
|
+
if (hoistFromSubWorkspaceYaml({ filesystem, rootWs, subPath })) {
|
|
88
86
|
rootChanged = true;
|
|
89
87
|
}
|
|
90
88
|
}
|
|
91
89
|
if (rootChanged) {
|
|
92
|
-
|
|
90
|
+
// Keep allowBuilds (pnpm 11) and onlyBuiltDependencies (pnpm 10) in sync so
|
|
91
|
+
// the build-script allowlist survives regardless of which key pnpm reads.
|
|
92
|
+
syncBuildAllowlists(rootWs);
|
|
93
|
+
filesystem.write(rootWsPath, (0, js_yaml_1.dump)(rootWs, { lineWidth: -1, sortKeys: false }));
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
/**
|
|
96
|
-
* Move the workspace-scoped pnpm fields from `source` into `
|
|
97
|
+
* Move the workspace-scoped pnpm fields from `source` into `rootWs`,
|
|
97
98
|
* deleting each moved field from `source`. Returns true if anything moved.
|
|
98
99
|
*/
|
|
99
|
-
function hoistFields(
|
|
100
|
+
function hoistFields(rootWs, source) {
|
|
100
101
|
let changed = false;
|
|
101
102
|
for (const field of WORKSPACE_SCOPED_PNPM_FIELDS) {
|
|
102
103
|
if (source[field] === undefined)
|
|
103
104
|
continue;
|
|
104
|
-
|
|
105
|
+
rootWs[field] = mergePnpmFieldValue(field, rootWs[field], source[field]);
|
|
105
106
|
delete source[field];
|
|
106
107
|
changed = true;
|
|
107
108
|
}
|
|
@@ -109,14 +110,14 @@ function hoistFields(rootPnpm, source) {
|
|
|
109
110
|
}
|
|
110
111
|
/** Source 1: the sub-project's package.json `pnpm` block. */
|
|
111
112
|
function hoistFromSubPackageJson(options) {
|
|
112
|
-
const { filesystem,
|
|
113
|
+
const { filesystem, rootWs, subPath } = options;
|
|
113
114
|
const subPkgPath = `${subPath}/package.json`;
|
|
114
115
|
if (!filesystem.exists(subPkgPath))
|
|
115
116
|
return false;
|
|
116
117
|
const subPkg = filesystem.read(subPkgPath, 'json');
|
|
117
118
|
if (!(subPkg === null || subPkg === void 0 ? void 0 : subPkg.pnpm))
|
|
118
119
|
return false;
|
|
119
|
-
if (!hoistFields(
|
|
120
|
+
if (!hoistFields(rootWs, subPkg.pnpm))
|
|
120
121
|
return false;
|
|
121
122
|
// If the sub-project's pnpm section is now empty, drop it entirely.
|
|
122
123
|
if (Object.keys(subPkg.pnpm).length === 0) {
|
|
@@ -125,37 +126,23 @@ function hoistFromSubPackageJson(options) {
|
|
|
125
126
|
filesystem.write(subPkgPath, `${JSON.stringify(subPkg, null, 2)}\n`);
|
|
126
127
|
return true;
|
|
127
128
|
}
|
|
128
|
-
/** Source 2: the sub-project's pnpm-workspace.yaml
|
|
129
|
+
/** Source 2: the sub-project's pnpm-workspace.yaml. */
|
|
129
130
|
function hoistFromSubWorkspaceYaml(options) {
|
|
130
|
-
const { filesystem,
|
|
131
|
+
const { filesystem, rootWs, subPath } = options;
|
|
131
132
|
const subWsPath = `${subPath}/pnpm-workspace.yaml`;
|
|
132
133
|
if (!filesystem.exists(subWsPath))
|
|
133
134
|
return false;
|
|
134
|
-
const
|
|
135
|
-
if (!
|
|
135
|
+
const ws = readYaml(filesystem, subWsPath);
|
|
136
|
+
if (!ws)
|
|
136
137
|
return false;
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
parsed = (0, js_yaml_1.load)(raw);
|
|
140
|
-
}
|
|
141
|
-
catch (_a) {
|
|
142
|
-
// Malformed YAML — leave it untouched rather than risk data loss.
|
|
143
|
-
return false;
|
|
144
|
-
}
|
|
145
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
146
|
-
return false;
|
|
147
|
-
const ws = parsed;
|
|
148
|
-
// Fold the pnpm-11 `allowBuilds` map into `onlyBuiltDependencies` so it is
|
|
149
|
-
// hoisted rather than discarded — even when the array twin is absent.
|
|
150
|
-
normalizeAllowBuilds(ws);
|
|
151
|
-
if (!hoistFields(rootPnpm, ws))
|
|
138
|
+
if (!hoistFields(rootWs, ws))
|
|
152
139
|
return false;
|
|
153
140
|
// A settings-only file (no `packages:`) exists solely to carry these
|
|
154
141
|
// hoisted keys — once emptied it would only declare a nested workspace
|
|
155
142
|
// root, so remove it. A file that declares `packages:` is a real (rare)
|
|
156
143
|
// nested workspace; keep it minus the hoisted keys.
|
|
157
144
|
if (Array.isArray(ws.packages) && ws.packages.length > 0) {
|
|
158
|
-
filesystem.write(subWsPath, (0, js_yaml_1.dump)(ws));
|
|
145
|
+
filesystem.write(subWsPath, (0, js_yaml_1.dump)(ws, { lineWidth: -1, sortKeys: false }));
|
|
159
146
|
}
|
|
160
147
|
else {
|
|
161
148
|
filesystem.remove(subWsPath);
|
|
@@ -174,19 +161,16 @@ function isSymlink(path) {
|
|
|
174
161
|
/**
|
|
175
162
|
* Merge two values for a pnpm workspace-scoped field.
|
|
176
163
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* - `onlyBuiltDependencies` → array of strings
|
|
180
|
-
* - `ignoredOptionalDependencies` → array of strings
|
|
164
|
+
* Arrays (`onlyBuiltDependencies`, `ignoredOptionalDependencies`,
|
|
165
|
+
* `minimumReleaseAgeExclude`): deduplicated, alphabetically sorted union.
|
|
181
166
|
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* handlebars/minimatch patches).
|
|
167
|
+
* Objects (`overrides`, `allowBuilds`): key-by-key merge where sub-project
|
|
168
|
+
* values take precedence over root (sub-projects like nest-server-starter
|
|
169
|
+
* own the authoritative CVE override list for their transitive deps; the
|
|
170
|
+
* root usually only seeds cross-cutting patches).
|
|
187
171
|
*/
|
|
188
172
|
function mergePnpmFieldValue(field, rootValue, subValue) {
|
|
189
|
-
if (field
|
|
173
|
+
if (isArrayField(field)) {
|
|
190
174
|
const rootArr = Array.isArray(rootValue) ? rootValue : [];
|
|
191
175
|
const subArr = Array.isArray(subValue) ? subValue : [];
|
|
192
176
|
return Array.from(new Set([...rootArr, ...subArr])).sort((a, b) => a.localeCompare(b));
|
|
@@ -198,26 +182,55 @@ function mergePnpmFieldValue(field, rootValue, subValue) {
|
|
|
198
182
|
const merged = Object.assign(Object.assign({}, rootObj), subObj);
|
|
199
183
|
return Object.fromEntries(Object.entries(merged).sort(([a], [b]) => a.localeCompare(b)));
|
|
200
184
|
}
|
|
185
|
+
/** Parse a YAML file into a plain object, or null on missing/malformed/non-object. */
|
|
186
|
+
function readYaml(filesystem, path) {
|
|
187
|
+
if (!filesystem.exists(path))
|
|
188
|
+
return null;
|
|
189
|
+
const raw = filesystem.read(path);
|
|
190
|
+
if (!raw)
|
|
191
|
+
return null;
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
parsed = (0, js_yaml_1.load)(raw);
|
|
195
|
+
}
|
|
196
|
+
catch (_a) {
|
|
197
|
+
// Malformed YAML — leave it untouched rather than risk data loss.
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
201
|
+
return null;
|
|
202
|
+
return parsed;
|
|
203
|
+
}
|
|
201
204
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* redundant object form does not linger. Mutates `ws` in place.
|
|
205
|
+
* Keep the two build-allowlist forms consistent and complete after hoisting:
|
|
206
|
+
* - `allowBuilds` (pnpm 11) is a `{ pkg: boolean }` map.
|
|
207
|
+
* - `onlyBuiltDependencies` (pnpm 10) is a `string[]` of allowed packages.
|
|
206
208
|
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
+
* Sub-templates carry one or both (nest-server-starter only `allowBuilds`,
|
|
210
|
+
* nuxt-base-template both). After merging we derive the full set of allowed
|
|
211
|
+
* packages from BOTH forms and write back canonical, sorted twins so the
|
|
212
|
+
* allowlist survives whichever key the active pnpm version reads. Explicit
|
|
213
|
+
* `false` entries in `allowBuilds` are preserved (and excluded from the
|
|
214
|
+
* array). No-op when neither key is present. Mutates `ws` in place.
|
|
209
215
|
*/
|
|
210
|
-
function
|
|
211
|
-
const
|
|
212
|
-
|
|
216
|
+
function syncBuildAllowlists(ws) {
|
|
217
|
+
const arr = Array.isArray(ws.onlyBuiltDependencies) ? ws.onlyBuiltDependencies : [];
|
|
218
|
+
const obj = ws.allowBuilds && typeof ws.allowBuilds === 'object' && !Array.isArray(ws.allowBuilds)
|
|
219
|
+
? ws.allowBuilds
|
|
220
|
+
: {};
|
|
221
|
+
if (arr.length === 0 && Object.keys(obj).length === 0)
|
|
213
222
|
return;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
+
// Every array entry implies allowBuilds[entry] = true unless already set.
|
|
224
|
+
const map = {};
|
|
225
|
+
for (const [pkg, enabled] of Object.entries(obj))
|
|
226
|
+
map[pkg] = enabled === true;
|
|
227
|
+
for (const pkg of arr)
|
|
228
|
+
if (map[pkg] === undefined)
|
|
229
|
+
map[pkg] = true;
|
|
230
|
+
const allowed = Object.entries(map)
|
|
231
|
+
.filter(([, enabled]) => enabled)
|
|
232
|
+
.map(([pkg]) => pkg)
|
|
233
|
+
.sort((a, b) => a.localeCompare(b));
|
|
234
|
+
ws.allowBuilds = Object.fromEntries(Object.entries(map).sort(([a], [b]) => a.localeCompare(b)));
|
|
235
|
+
ws.onlyBuiltDependencies = allowed;
|
|
223
236
|
}
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Quiet, report-driven wrapper around the project `check` pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Replaces the noisy `pnpm audit && pnpm -r --parallel run check` with:
|
|
6
|
+
* - a minimal live view — one status line per running project (spinner +
|
|
7
|
+
* current step), so you always see where the run is;
|
|
8
|
+
* - abort on the first failing step, printing the captured reason;
|
|
9
|
+
* - on success a report: the executed steps + their key metrics
|
|
10
|
+
* (vulnerabilities per level, test counts per area Unit/API/Playwright, …);
|
|
11
|
+
* - format + lint auto-fix every fixable finding (oxfmt writes, oxlint --fix);
|
|
12
|
+
* only non-fixable lint errors then remain and fail the run.
|
|
13
|
+
*
|
|
14
|
+
* Flags:
|
|
15
|
+
* --verbose / -v stream the full tool output live (deep debugging)
|
|
16
|
+
* --sequential/--seq run projects one after another (default: parallel)
|
|
17
|
+
* --no-fix read-only gate — do not auto-fix format/lint
|
|
18
|
+
* --project=<substr> restrict to matching workspace projects (repeatable)
|
|
19
|
+
*
|
|
20
|
+
* Design: the per-project `check` scripts stay the single source of truth for
|
|
21
|
+
* WHAT runs. This wrapper discovers each workspace project's `check` chain,
|
|
22
|
+
* splits it on `&&`, and runs the steps with status + metrics — so adding or
|
|
23
|
+
* removing a step in a project's `check` needs no change here.
|
|
24
|
+
*
|
|
25
|
+
* Exit code: 0 when every step passed, 1 otherwise (preserves the contract the
|
|
26
|
+
* lt-dev `running-check-script` skill relies on: non-zero === failed).
|
|
27
|
+
*/
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
|
|
33
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
34
|
+
const VERBOSE = process.argv.includes("--verbose") || process.argv.includes("-v");
|
|
35
|
+
const SEQUENTIAL = process.argv.includes("--sequential") || process.argv.includes("--seq");
|
|
36
|
+
const NO_FIX = process.argv.includes("--no-fix");
|
|
37
|
+
const PROJECT_FILTERS = process.argv
|
|
38
|
+
.filter((a) => a.startsWith("--project="))
|
|
39
|
+
.map((a) => a.slice("--project=".length));
|
|
40
|
+
// Verbose streams raw output, so the in-place live view is disabled there.
|
|
41
|
+
const TTY = Boolean(process.stdout.isTTY) && !VERBOSE;
|
|
42
|
+
|
|
43
|
+
// ── tiny ANSI helpers ──────────────────────────────────────────────────────
|
|
44
|
+
const C = {
|
|
45
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
46
|
+
cyan: (s) => `\x1b[36m${s}\x1b[0m`,
|
|
47
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
48
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
49
|
+
red: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
50
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
51
|
+
};
|
|
52
|
+
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
53
|
+
const shortRel = (rel) => rel.replace(/^projects\//, "");
|
|
54
|
+
|
|
55
|
+
function fmtDuration(ms) {
|
|
56
|
+
const s = ms / 1000;
|
|
57
|
+
if (s < 60) return `${s.toFixed(1)}s`;
|
|
58
|
+
const m = Math.floor(s / 60);
|
|
59
|
+
return `${m}m ${Math.round(s - m * 60)}s`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── step classification ────────────────────────────────────────────────────
|
|
63
|
+
// Map a raw command from a `check` chain onto a stable kind + label so the
|
|
64
|
+
// report stays readable regardless of the underlying tool (oxfmt/oxlint/tsc/…).
|
|
65
|
+
function classify(cmd) {
|
|
66
|
+
const c = cmd.toLowerCase();
|
|
67
|
+
if (c.includes("vendor-freshness"))
|
|
68
|
+
return { fatal: false, kind: "vendor", label: "vendor-freshness" };
|
|
69
|
+
if (c.includes("audit")) return { fatal: true, kind: "audit", label: "audit" };
|
|
70
|
+
if (c.includes("format:check") || c.includes("oxfmt") || c.includes("prettier"))
|
|
71
|
+
return { fatal: true, kind: "format", label: "format" };
|
|
72
|
+
if (c.includes("lint")) return { fatal: true, kind: "lint", label: "lint" };
|
|
73
|
+
if (/(^|&|\s)(pnpm\s+)?test(:|\s|$)|vitest|jest|test:unit|test:ci/.test(c))
|
|
74
|
+
return { fatal: true, kind: "test", label: "test" };
|
|
75
|
+
if (c.includes("build") || c.includes("nuxt build") || c.includes("tsc"))
|
|
76
|
+
return { fatal: true, kind: "build", label: "build" };
|
|
77
|
+
if (c.includes("check-server-start") || c.includes("server-start"))
|
|
78
|
+
return { fatal: true, kind: "server", label: "server-start" };
|
|
79
|
+
return { fatal: true, kind: "other", label: cmd.length > 32 ? `${cmd.slice(0, 29)}…` : cmd };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Rewrite a check-only format/lint command into its auto-fixing variant, so a
|
|
83
|
+
// `check` run repairs every fixable finding instead of only reporting it.
|
|
84
|
+
function toFixCommand(kind, cmd) {
|
|
85
|
+
if (NO_FIX) return cmd;
|
|
86
|
+
if (kind === "format") {
|
|
87
|
+
if (/\bformat:check\b/.test(cmd)) return cmd.replace(/\bformat:check\b/, "format");
|
|
88
|
+
if (/\boxfmt\b/.test(cmd)) return cmd.replace(/\s--check\b/, "");
|
|
89
|
+
if (/\bprettier\b/.test(cmd)) return cmd.replace(/\s--check\b/, " --write");
|
|
90
|
+
return cmd;
|
|
91
|
+
}
|
|
92
|
+
if (kind === "lint") {
|
|
93
|
+
if (/\blint:fix\b/.test(cmd) || /--fix\b/.test(cmd)) return cmd;
|
|
94
|
+
if (/\brun\s+lint\b/.test(cmd)) return cmd.replace(/\brun\s+lint\b/, "run lint:fix");
|
|
95
|
+
if (/\boxlint\b/.test(cmd)) return cmd.replace(/\boxlint\b/, "oxlint --fix --fix-suggestions");
|
|
96
|
+
return cmd;
|
|
97
|
+
}
|
|
98
|
+
return cmd;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── metric parsers ─────────────────────────────────────────────────────────
|
|
102
|
+
function parseVitest(out) {
|
|
103
|
+
const clean = stripAnsi(out);
|
|
104
|
+
const tests = clean.match(/Tests\s+(?:(\d+)\s+failed[^\n]*?)?(\d+)\s+passed/i);
|
|
105
|
+
const files = clean.match(/Test Files\s+(?:(\d+)\s+failed[^\n]*?)?(\d+)\s+passed/i);
|
|
106
|
+
const failed = clean.match(/Tests\s+(\d+)\s+failed/i);
|
|
107
|
+
if (!tests && !files) return null;
|
|
108
|
+
return {
|
|
109
|
+
failed: failed ? Number(failed[1]) : 0,
|
|
110
|
+
files: files ? Number(files[2]) : null,
|
|
111
|
+
passed: tests ? Number(tests[2]) : null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function parseLint(out) {
|
|
115
|
+
const clean = stripAnsi(out);
|
|
116
|
+
const summary = clean.match(/Found\s+(\d+)\s+warnings?(?:\s+and\s+(\d+)\s+errors?)?/i);
|
|
117
|
+
if (summary) return { errors: summary[2] ? Number(summary[2]) : 0, warnings: Number(summary[1]) };
|
|
118
|
+
return {
|
|
119
|
+
errors: (clean.match(/\berror\b/gi) || []).length,
|
|
120
|
+
warnings: (clean.match(/\bwarning\b/g) || []).length,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── audit (faithful: runs the project's OWN audit command) ──────────────────
|
|
125
|
+
const SEVERITIES = ["critical", "high", "moderate", "low", "info"];
|
|
126
|
+
|
|
127
|
+
// Run the audit command exactly as the check chain defines it (same scope /
|
|
128
|
+
// --prod / --audit-level), only appending --json for the counts. The gate is
|
|
129
|
+
// the command's own exit code, so `check` blocks precisely when a bare
|
|
130
|
+
// `<auditCmd>` would — never with a narrower scope than the chain. (The old
|
|
131
|
+
// hardcoded `--prod` hid devDependency vulns for library packages.)
|
|
132
|
+
async function runAudit(auditCmd) {
|
|
133
|
+
const cmd = /(^|\s)--json(\s|$)/.test(auditCmd) ? auditCmd : `${auditCmd} --json`;
|
|
134
|
+
const { code, out } = await capture(cmd, ROOT);
|
|
135
|
+
let counts = null;
|
|
136
|
+
try {
|
|
137
|
+
counts = JSON.parse(out.slice(out.indexOf("{")))?.metadata?.vulnerabilities ?? null;
|
|
138
|
+
} catch {
|
|
139
|
+
/* fall through to raw reason */
|
|
140
|
+
}
|
|
141
|
+
const total = counts ? SEVERITIES.reduce((n, s) => n + (counts[s] || 0), 0) : 0;
|
|
142
|
+
return { auditCmd, blocking: code !== 0, counts, reason: counts ? null : out, total };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── command runner ─────────────────────────────────────────────────────────
|
|
146
|
+
const RUNNING = new Set();
|
|
147
|
+
function capture(cmd, cwd) {
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
const child = spawn(cmd, { cwd, shell: true });
|
|
150
|
+
RUNNING.add(child);
|
|
151
|
+
let out = "";
|
|
152
|
+
const onData = (d) => {
|
|
153
|
+
out += d;
|
|
154
|
+
if (VERBOSE) process.stdout.write(d);
|
|
155
|
+
};
|
|
156
|
+
child.stdout.on("data", onData);
|
|
157
|
+
child.stderr.on("data", onData);
|
|
158
|
+
const done = (code, extra) => {
|
|
159
|
+
RUNNING.delete(child);
|
|
160
|
+
resolve({ code, out: extra ? `${out}\n${extra}` : out });
|
|
161
|
+
};
|
|
162
|
+
child.on("close", (code) => done(code ?? 1));
|
|
163
|
+
child.on("error", (err) => done(1, err.message));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function killAll() {
|
|
167
|
+
for (const child of RUNNING) {
|
|
168
|
+
try {
|
|
169
|
+
child.kill("SIGTERM");
|
|
170
|
+
} catch {
|
|
171
|
+
/* already gone */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── live multi-line status (one line per running project) ────────────────────
|
|
177
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
178
|
+
let liveCount = 0;
|
|
179
|
+
let frame = 0;
|
|
180
|
+
function drawLive(lines) {
|
|
181
|
+
if (!TTY) return;
|
|
182
|
+
if (liveCount > 0) process.stdout.write(`\x1b[${liveCount}A`);
|
|
183
|
+
for (const l of lines) process.stdout.write(`\r\x1b[K${l}\n`);
|
|
184
|
+
liveCount = lines.length;
|
|
185
|
+
}
|
|
186
|
+
function statusLines(order, states) {
|
|
187
|
+
frame += 1;
|
|
188
|
+
return order.map((rel) => {
|
|
189
|
+
const s = states.get(rel);
|
|
190
|
+
if (s.failed) return `${C.red("✗")} ${shortRel(rel).padEnd(5)} ${C.red(`${s.failed} FAILED`)}`;
|
|
191
|
+
if (s.done)
|
|
192
|
+
return `${C.green("✓")} ${shortRel(rel).padEnd(5)} ${C.dim(`done (${fmtDuration(s.total)})`)}`;
|
|
193
|
+
const spin = C.cyan(FRAMES[frame % FRAMES.length]);
|
|
194
|
+
const el = s.stepStart ? C.dim(` (${fmtDuration(Date.now() - s.stepStart)})`) : "";
|
|
195
|
+
return `${spin} ${shortRel(rel).padEnd(5)} ${s.current || "queued"}${el}`;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── project discovery + step grouping ────────────────────────────────────────
|
|
200
|
+
const IS_ORCHESTRATOR = (script) => !script || script.includes("check.mjs");
|
|
201
|
+
|
|
202
|
+
// Read the `packages:` globs from pnpm-workspace.yaml (monorepos). A simple
|
|
203
|
+
// value-list parse — enough for the globs lt projects use (e.g. `projects/*`).
|
|
204
|
+
function workspaceGlobs() {
|
|
205
|
+
let text;
|
|
206
|
+
try {
|
|
207
|
+
text = readFileSync(join(ROOT, "pnpm-workspace.yaml"), "utf8");
|
|
208
|
+
} catch {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
const globs = [];
|
|
212
|
+
let inPackages = false;
|
|
213
|
+
for (const raw of text.split("\n")) {
|
|
214
|
+
const line = raw.replace(/#.*$/, "");
|
|
215
|
+
if (/^packages:\s*$/.test(line)) {
|
|
216
|
+
inPackages = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (inPackages) {
|
|
220
|
+
const m = line.match(/^\s*-\s*['"]?([^'"]+?)['"]?\s*$/);
|
|
221
|
+
if (m) globs.push(m[1]);
|
|
222
|
+
else if (line.trim() && !/^\s/.test(line)) break; // next top-level key
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return globs;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Expand a workspace glob to concrete directories (handles `dir/*` and literals).
|
|
229
|
+
function expandGlob(glob) {
|
|
230
|
+
if (glob.endsWith("/*")) {
|
|
231
|
+
const base = glob.slice(0, -2);
|
|
232
|
+
try {
|
|
233
|
+
return readdirSync(join(ROOT, base), { withFileTypes: true })
|
|
234
|
+
.filter((d) => d.isDirectory())
|
|
235
|
+
.map((d) => join(base, d.name));
|
|
236
|
+
} catch {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return [glob];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function asProject(rel, check) {
|
|
244
|
+
let pkg = {};
|
|
245
|
+
try {
|
|
246
|
+
pkg = JSON.parse(
|
|
247
|
+
readFileSync(
|
|
248
|
+
rel === "." ? join(ROOT, "package.json") : join(ROOT, rel, "package.json"),
|
|
249
|
+
"utf8",
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
} catch {
|
|
253
|
+
/* keep defaults */
|
|
254
|
+
}
|
|
255
|
+
return { check, dir: rel === "." ? ROOT : join(ROOT, rel), name: pkg.name || rel, rel };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Workspace sub-projects whose `check` is a real chain; if there are none (a
|
|
259
|
+
// single-package repo), fall back to the root project — whose real chain lives
|
|
260
|
+
// in `check:raw`, because the root `check` is THIS wrapper.
|
|
261
|
+
function discoverProjects() {
|
|
262
|
+
const projects = [];
|
|
263
|
+
for (const glob of workspaceGlobs()) {
|
|
264
|
+
for (const rel of expandGlob(glob)) {
|
|
265
|
+
let pkg;
|
|
266
|
+
try {
|
|
267
|
+
pkg = JSON.parse(readFileSync(join(ROOT, rel, "package.json"), "utf8"));
|
|
268
|
+
} catch {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!IS_ORCHESTRATOR(pkg.scripts?.check)) projects.push(asProject(rel, pkg.scripts.check));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (projects.length === 0) {
|
|
275
|
+
const root = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
|
|
276
|
+
const chain =
|
|
277
|
+
root.scripts?.["check:raw"] ??
|
|
278
|
+
(IS_ORCHESTRATOR(root.scripts?.check) ? null : root.scripts?.check);
|
|
279
|
+
if (chain) projects.push(asProject(".", chain));
|
|
280
|
+
}
|
|
281
|
+
if (PROJECT_FILTERS.length)
|
|
282
|
+
return projects.filter((p) =>
|
|
283
|
+
PROJECT_FILTERS.some((f) => p.rel.includes(f) || p.name.includes(f)),
|
|
284
|
+
);
|
|
285
|
+
return projects;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// One group per project: its ordered, fix-mapped steps. The audit step is
|
|
289
|
+
// hoisted to a single workspace-level run; its EXACT command (scope + level +
|
|
290
|
+
// package manager) is captured so the run mirrors the chain's own audit.
|
|
291
|
+
function buildGroups(projects) {
|
|
292
|
+
let auditCmd = null;
|
|
293
|
+
const groups = projects.map((project) => {
|
|
294
|
+
const steps = [];
|
|
295
|
+
for (const raw of project.check
|
|
296
|
+
.split("&&")
|
|
297
|
+
.map((s) => s.trim())
|
|
298
|
+
.filter(Boolean)) {
|
|
299
|
+
const meta = classify(raw);
|
|
300
|
+
if (meta.kind === "audit") {
|
|
301
|
+
if (!auditCmd) auditCmd = raw;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
steps.push({ ...meta, cmd: toFixCommand(meta.kind, raw), cwd: project.dir });
|
|
305
|
+
}
|
|
306
|
+
return { project, steps };
|
|
307
|
+
});
|
|
308
|
+
return { auditCmd, groups };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ── per-project runner ───────────────────────────────────────────────────────
|
|
312
|
+
// Runs a group's steps in order, recording results + live state. Stops early
|
|
313
|
+
// when another project already failed (abort.hit).
|
|
314
|
+
async function runGroup(group, states, results, abort) {
|
|
315
|
+
const rel = group.project.rel;
|
|
316
|
+
const st = states.get(rel);
|
|
317
|
+
const startedAt = Date.now();
|
|
318
|
+
for (const step of group.steps) {
|
|
319
|
+
if (abort.hit) return;
|
|
320
|
+
st.current = step.label;
|
|
321
|
+
st.stepStart = Date.now();
|
|
322
|
+
if (!TTY) process.stdout.write(` ${C.dim("→")} ${shortRel(rel)} · ${step.label}\n`);
|
|
323
|
+
const { code, out } = await capture(step.cmd, step.cwd);
|
|
324
|
+
const dur = Date.now() - st.stepStart;
|
|
325
|
+
const r = { dur, kind: step.kind, label: step.label, project: rel };
|
|
326
|
+
if (step.kind === "test") r.tests = parseVitest(out);
|
|
327
|
+
if (step.kind === "lint") r.lint = parseLint(out);
|
|
328
|
+
results.push(r);
|
|
329
|
+
if (code !== 0 && step.fatal) {
|
|
330
|
+
st.failed = step.label;
|
|
331
|
+
if (!abort.hit) {
|
|
332
|
+
abort.hit = true;
|
|
333
|
+
abort.failure = { out, project: rel, step: `${shortRel(rel)} · ${step.label}` };
|
|
334
|
+
killAll();
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (!TTY)
|
|
339
|
+
process.stdout.write(
|
|
340
|
+
` ${C.green("✓")} ${shortRel(rel)} · ${step.label}${metricSuffix(r)} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
st.done = true;
|
|
344
|
+
st.total = Date.now() - startedAt;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── main ─────────────────────────────────────────────────────────────────────
|
|
348
|
+
async function main() {
|
|
349
|
+
const started = Date.now();
|
|
350
|
+
const projects = discoverProjects();
|
|
351
|
+
if (projects.length === 0) {
|
|
352
|
+
console.error(C.red("No workspace projects with a `check` script found."));
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
const { auditCmd, groups } = buildGroups(projects);
|
|
356
|
+
const stepCount = groups.reduce((n, g) => n + g.steps.length, 0) + (auditCmd ? 1 : 0);
|
|
357
|
+
const mode = SEQUENTIAL ? "sequential" : "parallel";
|
|
358
|
+
const pkgName = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).name;
|
|
359
|
+
|
|
360
|
+
console.log(C.bold(`\nRunning checks for ${C.cyan(pkgName)}`));
|
|
361
|
+
console.log(
|
|
362
|
+
C.dim(
|
|
363
|
+
`${projects.length} project(s) · ${stepCount} steps · ${mode} · audit: ${auditCmd ?? "none"}` +
|
|
364
|
+
`${NO_FIX ? "" : " · auto-fix format+lint"}${VERBOSE ? " · verbose" : ""}\n`,
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const results = [];
|
|
369
|
+
|
|
370
|
+
// Step 0 — single workspace audit (blocking gate, runs before the fan-out).
|
|
371
|
+
// Mirrors the chain's own audit command (scope/level/PM); skipped only when
|
|
372
|
+
// the chain has no audit step.
|
|
373
|
+
if (auditCmd) {
|
|
374
|
+
const t = Date.now();
|
|
375
|
+
if (!TTY) process.stdout.write(` ${C.dim("→")} audit\n`);
|
|
376
|
+
else drawLive([`${C.cyan(FRAMES[0])} audit`]);
|
|
377
|
+
const audit = await runAudit(auditCmd);
|
|
378
|
+
liveCount = 0;
|
|
379
|
+
const dur = Date.now() - t;
|
|
380
|
+
if (audit.blocking) {
|
|
381
|
+
const summary = audit.counts
|
|
382
|
+
? `${audit.total} vuln (${renderVulnLine(audit.counts)})`
|
|
383
|
+
: "failed";
|
|
384
|
+
console.log(`${C.red("✗")} audit ${C.red(summary)} ${C.dim(`(${fmtDuration(dur)})`)}`);
|
|
385
|
+
return fail(
|
|
386
|
+
`audit (${auditCmd})`,
|
|
387
|
+
audit.counts ? renderVulnLine(audit.counts) : audit.reason,
|
|
388
|
+
started,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
console.log(
|
|
392
|
+
`${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}`,
|
|
393
|
+
);
|
|
394
|
+
results.push({ audit, kind: "audit" });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Per-project steps — parallel by default, serial with --sequential.
|
|
398
|
+
const order = groups.map((g) => g.project.rel);
|
|
399
|
+
const states = new Map(order.map((rel) => [rel, { current: "queued" }]));
|
|
400
|
+
const abort = { failure: null, hit: false };
|
|
401
|
+
const ticker = TTY ? setInterval(() => drawLive(statusLines(order, states)), 80) : null;
|
|
402
|
+
if (TTY) drawLive(statusLines(order, states));
|
|
403
|
+
|
|
404
|
+
if (SEQUENTIAL) {
|
|
405
|
+
for (const g of groups) {
|
|
406
|
+
await runGroup(g, states, results, abort);
|
|
407
|
+
if (abort.hit) break;
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
await Promise.all(groups.map((g) => runGroup(g, states, results, abort)));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (ticker) clearInterval(ticker);
|
|
414
|
+
if (TTY) drawLive(statusLines(order, states)); // final frame
|
|
415
|
+
|
|
416
|
+
if (abort.hit) return fail(abort.failure.step, abort.failure.out, started);
|
|
417
|
+
|
|
418
|
+
report(started, results);
|
|
419
|
+
process.exit(0);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── rendering helpers ─────────────────────────────────────────────────────────
|
|
423
|
+
function renderVulnLine(counts) {
|
|
424
|
+
return SEVERITIES.map((s) => {
|
|
425
|
+
const n = counts[s] || 0;
|
|
426
|
+
const txt = `${s} ${n}`;
|
|
427
|
+
if (n > 0 && (s === "critical" || s === "high")) return C.red(txt);
|
|
428
|
+
return n > 0 ? C.yellow(txt) : C.dim(txt);
|
|
429
|
+
}).join(C.dim(" · "));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function metricSuffix(r) {
|
|
433
|
+
if (r.kind === "test" && r.tests?.passed != null) {
|
|
434
|
+
const failed = r.tests.failed ? C.red(` / ${r.tests.failed} failed`) : "";
|
|
435
|
+
return ` ${C.dim(`${r.tests.passed} passed${r.tests.files != null ? ` / ${r.tests.files} files` : ""}`)}${failed}`;
|
|
436
|
+
}
|
|
437
|
+
if (r.kind === "lint" && r.lint) {
|
|
438
|
+
return r.lint.warnings > 0
|
|
439
|
+
? ` ${C.yellow(`${r.lint.warnings} warning${r.lint.warnings === 1 ? "" : "s"}`)}`
|
|
440
|
+
: ` ${C.dim("clean")}`;
|
|
441
|
+
}
|
|
442
|
+
return "";
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function fail(stepLabel, reason, started) {
|
|
446
|
+
console.log(`\n${C.red(`──── reason · ${stepLabel} ────`)}`);
|
|
447
|
+
console.log(stripAnsi(String(reason)).trimEnd().split("\n").slice(-40).join("\n"));
|
|
448
|
+
console.log(C.red("────────────────────────────────────────\n"));
|
|
449
|
+
console.log(
|
|
450
|
+
C.bold(
|
|
451
|
+
C.red(`✗ Check FAILED at step "${stepLabel}" after ${fmtDuration(Date.now() - started)}.`),
|
|
452
|
+
),
|
|
453
|
+
);
|
|
454
|
+
console.log(C.dim("Re-run with --verbose for the full output of every step."));
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function report(started, results) {
|
|
459
|
+
const audit = results.find((r) => r.kind === "audit")?.audit;
|
|
460
|
+
const tests = results.filter((r) => r.kind === "test");
|
|
461
|
+
const unit = tests.find((r) => r.project?.includes("app"))?.tests;
|
|
462
|
+
const api = tests.find((r) => r.project?.includes("api"))?.tests;
|
|
463
|
+
const totalPassed = tests.reduce((n, r) => n + (r.tests?.passed || 0), 0);
|
|
464
|
+
|
|
465
|
+
const bar = "═".repeat(52);
|
|
466
|
+
console.log(`\n${C.green(bar)}`);
|
|
467
|
+
console.log(
|
|
468
|
+
C.bold(` ${C.green("✓ Check PASSED")} ${C.dim(`(${fmtDuration(Date.now() - started)})`)}`),
|
|
469
|
+
);
|
|
470
|
+
console.log(C.green(bar));
|
|
471
|
+
|
|
472
|
+
console.log(`\n${C.bold("Steps")}`);
|
|
473
|
+
for (const r of results.filter((x) => x.kind !== "audit")) {
|
|
474
|
+
console.log(
|
|
475
|
+
` ${C.green("✓")} ${`${shortRel(r.project)} · ${r.label}`.padEnd(26)}${metricSuffix(r) || " "} ${C.dim(`(${fmtDuration(r.dur)})`)}`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
console.log(
|
|
480
|
+
`\n${C.bold("Vulnerabilities")} ${C.dim(audit ? `(${audit.auditCmd})` : "(no audit step)")}`,
|
|
481
|
+
);
|
|
482
|
+
console.log(
|
|
483
|
+
` ${audit?.counts ? renderVulnLine(audit.counts) : C.dim(audit ? "counts unavailable" : "—")}`,
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
console.log(`\n${C.bold("Tests")}`);
|
|
487
|
+
if (unit || api) {
|
|
488
|
+
// Monorepo with app and/or api projects → the canonical area breakdown.
|
|
489
|
+
console.log(
|
|
490
|
+
` ${"Unit (app)".padEnd(18)}${unit?.passed != null ? `${unit.passed} passed` : C.dim("—")}`,
|
|
491
|
+
);
|
|
492
|
+
console.log(
|
|
493
|
+
` ${"API (api)".padEnd(18)}${api?.passed != null ? `${api.passed} passed` : C.dim("—")}`,
|
|
494
|
+
);
|
|
495
|
+
console.log(` ${"Playwright".padEnd(18)}${C.dim("— (run via `lt dev test` / CI)")}`);
|
|
496
|
+
} else {
|
|
497
|
+
// Single-package repo → one line per test-bearing project.
|
|
498
|
+
for (const r of tests)
|
|
499
|
+
console.log(
|
|
500
|
+
` ${shortRel(r.project).padEnd(18)}${r.tests?.passed != null ? `${r.tests.passed} passed` : C.dim("—")}`,
|
|
501
|
+
);
|
|
502
|
+
if (tests.length === 0) console.log(` ${C.dim("no test step")}`);
|
|
503
|
+
}
|
|
504
|
+
console.log(` ${C.bold("Total".padEnd(18))}${C.bold(`${totalPassed} passed`)}`);
|
|
505
|
+
|
|
506
|
+
console.log(`\n${C.green("All checks passed.")}\n`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
main().catch((err) => {
|
|
510
|
+
console.error(C.red(`\ncheck.mjs crashed: ${err?.stack || err}`));
|
|
511
|
+
process.exit(1);
|
|
512
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.0",
|
|
4
4
|
"description": "lenne.Tech CLI: lt",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"lenne.Tech",
|
|
@@ -104,14 +104,16 @@
|
|
|
104
104
|
"semver@*": "Force latest semver 7.x across all sub-deps; gluegun@5.2.2 pins semver@7.7.0 which is stale - remove once gluegun updates its dep.",
|
|
105
105
|
"js-yaml": "Security fix: GHSA-h67p-54hq-rp68 (quadratic-complexity DoS in merge-key handling) in js-yaml <=4.1.1. Forces 4.2.0 everywhere, incl. the 3.14.2 pulled transitively by @istanbuljs/load-nyc-config under babel-plugin-istanbul (jest coverage); that loader only runs js-yaml when reading a YAML nyc config, which this project does not use. Remove once the jest toolchain resolves js-yaml >=4.2.0 itself.",
|
|
106
106
|
"form-data": "Security fix: GHSA-hmw2-7cc7-3qxx (CRLF injection via unescaped multipart field names) in form-data 4.0.0-4.0.5 - transitive via axios. Remove once axios pins form-data >=4.0.6.",
|
|
107
|
-
"@babel/core": "Security fix: GHSA-4x5r-pxfx-6jf8 (arbitrary file read via sourceMappingURL) in @babel/core <=7.29.0 - transitive via the jest toolchain. Remove once jest resolves @babel/core >=7.29.6."
|
|
107
|
+
"@babel/core": "Security fix: GHSA-4x5r-pxfx-6jf8 (arbitrary file read via sourceMappingURL) in @babel/core <=7.29.0 - transitive via the jest toolchain. Remove once jest resolves @babel/core >=7.29.6.",
|
|
108
|
+
"undici": "Security fix: GHSA-vmh5-mc38-953g (TLS certificate validation bypass via dropped requestTls in SOCKS5 ProxyAgent) and GHSA-pr7r-676h-xcf6 (cross-user info disclosure via shared cache whitespace bypass) in undici 7.0.0-7.27.2 - transitive via jsdom (jest test environment). Forces 7.28.0, which satisfies jsdom's ^7.25.0 range. Remove once jsdom resolves undici >=7.28.0."
|
|
108
109
|
},
|
|
109
110
|
"overrides": {
|
|
110
111
|
"@babel/core": "7.29.7",
|
|
111
112
|
"brace-expansion@5.0.2 - 5.0.5": "5.0.6",
|
|
112
113
|
"form-data": "4.0.6",
|
|
113
114
|
"js-yaml": "4.2.0",
|
|
114
|
-
"semver@*": "7.8.1"
|
|
115
|
+
"semver@*": "7.8.1",
|
|
116
|
+
"undici": "7.28.0"
|
|
115
117
|
},
|
|
116
118
|
"jest": {
|
|
117
119
|
"testEnvironment": "node",
|