fg-devkit 1.21.1 → 1.21.2
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/README.md +22 -5
- package/configs/eslint.config.base.d.ts +16 -0
- package/package.json +5 -2
- package/scaffold/vue/package.json +1 -1
- package/scripts/audit-fix-plan.mjs +134 -0
- package/scripts/audit-fix-plan.test.mjs +107 -0
- package/scripts/audit-override-commit.mjs +100 -37
- package/scripts/fg-devkit.mjs +15 -1
- package/scripts/prune-release-age-exclude.mjs +161 -0
package/README.md
CHANGED
|
@@ -173,15 +173,18 @@ pnpm exec fgd audit-override-commit --no-commit # leave the changes in the work
|
|
|
173
173
|
younger than `minimumReleaseAge` (re-run later), when the package is a direct dependency (bump it with
|
|
174
174
|
`policy-upgrade-commit`), or when no patch exists (candidate for `auditConfig.ignoreGhsas`).
|
|
175
175
|
4. Prunes overrides that no longer change the audit.
|
|
176
|
-
5.
|
|
176
|
+
5. Removes `minimumReleaseAgeExclude` entries that are no longer needed (see `prune-release-age-exclude` below).
|
|
177
|
+
This step also runs when `pnpm audit` is clean.
|
|
178
|
+
6. If findings that are still reported have active (not expired) `<suppress>` entries in fg-devkit's shared
|
|
177
179
|
`configs/dependency-check-suppressions.xml`, writes the repo's `dependency-check-suppressions.xml` with only
|
|
178
180
|
those entries, so OWASP Dependency-Check accepts exactly the risks this repo has. Findings with no patch and
|
|
179
181
|
no suppression are listed so you can add one.
|
|
180
|
-
|
|
182
|
+
7. Installs and runs the repo's `lint-check`, `lint:ts`, `test`, `test:unit` and `build` scripts when present.
|
|
181
183
|
Nothing is committed if a check fails.
|
|
182
|
-
|
|
183
|
-
the override changes (`vuln(package): override version`, `vuln(): override N packages`)
|
|
184
|
-
|
|
184
|
+
8. Commits the lockfile refresh (`vuln(package): refresh lockfile` / `vuln(): refresh N packages in lockfile`),
|
|
185
|
+
the override changes (`vuln(package): override version`, `vuln(): override N packages`), the release-age
|
|
186
|
+
excludes (`chore(repo): prune minimumReleaseAgeExclude`) and the suppressions file
|
|
187
|
+
(`chore(repo): sync dependency-check suppressions`) separately.
|
|
185
188
|
|
|
186
189
|
Report, remove, or remove and auto-commit workspace overrides that no longer change `pnpm audit`:
|
|
187
190
|
|
|
@@ -191,6 +194,20 @@ pnpm exec fgd prune-overrides --fix
|
|
|
191
194
|
pnpm exec fgd prune-overrides --commit
|
|
192
195
|
```
|
|
193
196
|
|
|
197
|
+
Report, remove, or remove and auto-commit `minimumReleaseAgeExclude` entries that are no longer needed:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
pnpm exec fgd prune-release-age-exclude
|
|
201
|
+
pnpm exec fgd prune-release-age-exclude --fix
|
|
202
|
+
pnpm exec fgd prune-release-age-exclude --commit
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A pinned version (`name@1.2.3` or `name@1.2.3 || 1.2.4`) is only needed while it is in the lockfile and younger than
|
|
206
|
+
`minimumReleaseAge` (1440 minutes unless configured). Versions that are old enough, or no longer installed, are
|
|
207
|
+
removed, and an entry with no versions left is removed entirely. Name rules such as `my-package` or `@scope/*` allow
|
|
208
|
+
every future release on purpose and are always kept. With `--fix` the lockfile is re-verified against the release-age
|
|
209
|
+
policy, and `pnpm-workspace.yaml` is restored if pnpm rejects it.
|
|
210
|
+
|
|
194
211
|
## tsconfig in consuming repos
|
|
195
212
|
|
|
196
213
|
Use `fgd sync-project-configs` (or `fgd sync-tooling-configs`) to write the local `tsconfig.json` template from `fg-devkit`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Linter } from 'eslint'
|
|
2
|
+
|
|
3
|
+
export interface CreateBaseConfigOptions {
|
|
4
|
+
tsconfigRootDir?: string
|
|
5
|
+
tsconfigFiles?: string[]
|
|
6
|
+
additionalIgnores?: string[]
|
|
7
|
+
extraNonTypeAwareFiles?: string[]
|
|
8
|
+
enablePrettier?: boolean
|
|
9
|
+
enableI18n?: boolean
|
|
10
|
+
i18nLocaleDir?: string
|
|
11
|
+
additionalRules?: Linter.RulesRecord
|
|
12
|
+
additionalPlugins?: NonNullable<Linter.Config['plugins']>
|
|
13
|
+
additionalSettings?: NonNullable<Linter.Config['settings']>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function createBaseConfig(options?: CreateBaseConfigOptions): Linter.Config[]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fg-devkit",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.2",
|
|
4
4
|
"description": "Shared configs, scripts, and DX tooling for Vue / TypeScript frontend repos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
"exports": {
|
|
14
14
|
"./tsconfig.base.json": "./configs/tsconfig.base.json",
|
|
15
15
|
"./tsconfig.app.sync.json": "./configs/tsconfig.app.sync.json",
|
|
16
|
-
"./eslint.config.base.js":
|
|
16
|
+
"./eslint.config.base.js": {
|
|
17
|
+
"types": "./configs/eslint.config.base.d.ts",
|
|
18
|
+
"default": "./configs/eslint.config.base.js"
|
|
19
|
+
},
|
|
17
20
|
"./prettier.config.js": "./configs/prettier.config.js",
|
|
18
21
|
"./lefthook.yml": "./configs/lefthook.yml",
|
|
19
22
|
"./vscode-settings.json": "./configs/vscode-settings.json",
|
|
@@ -434,3 +434,137 @@ export function filterSuppressions(xmlText, ghsas, now = Date.now()) {
|
|
|
434
434
|
}
|
|
435
435
|
return result
|
|
436
436
|
}
|
|
437
|
+
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// minimumReleaseAgeExclude pruning
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Split a minimumReleaseAgeExclude entry into its package name and exact versions.
|
|
444
|
+
* `versions` is null for name rules (`fg-devkit`, `@scope/*`), which allow every
|
|
445
|
+
* future release of that package on purpose and are never pruned.
|
|
446
|
+
*/
|
|
447
|
+
export function parseReleaseAgeExcludeEntry(entry) {
|
|
448
|
+
const text = String(entry).trim()
|
|
449
|
+
const at = text.lastIndexOf('@')
|
|
450
|
+
if (at <= 0) return { name: text, versions: null }
|
|
451
|
+
return {
|
|
452
|
+
name: text.slice(0, at),
|
|
453
|
+
versions: text
|
|
454
|
+
.slice(at + 1)
|
|
455
|
+
.split('||')
|
|
456
|
+
.map((version) => version.trim())
|
|
457
|
+
.filter(Boolean),
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Decide which version-pinned minimumReleaseAgeExclude entries are still needed.
|
|
463
|
+
*
|
|
464
|
+
* A version is still needed only while it is resolved in the lockfile *and* younger than
|
|
465
|
+
* minimumReleaseAge. Anything else (old enough now, or no longer installed) is dropped.
|
|
466
|
+
* Name rules, patterns, non-exact versions and versions without a known publish time are
|
|
467
|
+
* kept unchanged, so nothing is removed on a guess.
|
|
468
|
+
*
|
|
469
|
+
* @param {string[]} entries
|
|
470
|
+
* @param {{
|
|
471
|
+
* lockfileVersions: Map<string, Set<string>>,
|
|
472
|
+
* publishTime: (name: string, version: string) => number | undefined,
|
|
473
|
+
* minimumReleaseAgeMinutes: number,
|
|
474
|
+
* now?: number,
|
|
475
|
+
* }} context
|
|
476
|
+
* @returns {{ entry: string, next: string | null, dropped: { version: string, reason: 'old-enough' | 'not-installed' }[] }[]}
|
|
477
|
+
* `next` is the entry to keep (`null` = remove the entry, same string = unchanged).
|
|
478
|
+
*/
|
|
479
|
+
export function planReleaseAgeExcludePrune(entries, context) {
|
|
480
|
+
const now = context.now ?? Date.now()
|
|
481
|
+
const maxAgeMs = context.minimumReleaseAgeMinutes * 60000
|
|
482
|
+
return entries.map((entry) => {
|
|
483
|
+
const { name, versions } = parseReleaseAgeExcludeEntry(entry)
|
|
484
|
+
if (versions === null || name.includes('*') || versions.length === 0) {
|
|
485
|
+
return { entry, next: entry, dropped: [] }
|
|
486
|
+
}
|
|
487
|
+
if (versions.some((version) => !parseVersion(version))) return { entry, next: entry, dropped: [] }
|
|
488
|
+
|
|
489
|
+
const kept = []
|
|
490
|
+
const dropped = []
|
|
491
|
+
for (const version of versions) {
|
|
492
|
+
if (!context.lockfileVersions.get(name)?.has(version)) {
|
|
493
|
+
dropped.push({ version, reason: 'not-installed' })
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
const published = context.publishTime(name, version)
|
|
497
|
+
if (published === undefined || !Number.isFinite(published)) {
|
|
498
|
+
kept.push(version)
|
|
499
|
+
} else if (now - published >= maxAgeMs) {
|
|
500
|
+
dropped.push({ version, reason: 'old-enough' })
|
|
501
|
+
} else {
|
|
502
|
+
kept.push(version)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (dropped.length === 0) return { entry, next: entry, dropped }
|
|
506
|
+
return { entry, next: kept.length > 0 ? `${name}@${kept.join(' || ')}` : null, dropped }
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Items of a top-level YAML block list (`key:` followed by ` - item` lines). */
|
|
511
|
+
export function readWorkspaceList(text, key) {
|
|
512
|
+
const lines = String(text ?? '').split('\n')
|
|
513
|
+
const start = lines.findIndex((line) => line === `${key}:` || line.startsWith(`${key}: #`))
|
|
514
|
+
if (start === -1) return []
|
|
515
|
+
const items = []
|
|
516
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
517
|
+
const line = lines[i]
|
|
518
|
+
if (line.trim() === '' || line.trim().startsWith('#')) continue
|
|
519
|
+
const item = line.match(/^\s+-\s+(.*?)\s*$/)
|
|
520
|
+
if (!item) break
|
|
521
|
+
items.push(unquoteYaml(item[1]))
|
|
522
|
+
}
|
|
523
|
+
return items
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function renderYamlListItem(value) {
|
|
527
|
+
return /^[\w@./-][\w@./ |<>=*^~-]*$/.test(value) && !/[:#]/.test(value) && !value.startsWith('@')
|
|
528
|
+
? value
|
|
529
|
+
: `'${value.replace(/'/g, "''")}'`
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Apply `{ entry -> next }` replacements to a top-level YAML block list, keeping
|
|
534
|
+
* indentation, quoting style and comments. `next === null` removes the item; the key is
|
|
535
|
+
* removed too when its list becomes empty.
|
|
536
|
+
*/
|
|
537
|
+
export function rewriteWorkspaceList(text, key, replacements) {
|
|
538
|
+
const lines = String(text ?? '').split('\n')
|
|
539
|
+
const start = lines.findIndex((line) => line === `${key}:` || line.startsWith(`${key}: #`))
|
|
540
|
+
if (start === -1) return text
|
|
541
|
+
let remaining = 0
|
|
542
|
+
let end = start + 1
|
|
543
|
+
const output = lines.slice(0, start + 1)
|
|
544
|
+
for (; end < lines.length; end += 1) {
|
|
545
|
+
const line = lines[end]
|
|
546
|
+
if (line.trim() === '' || line.trim().startsWith('#')) {
|
|
547
|
+
output.push(line)
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
const item = line.match(/^(\s+-\s+)(.*?)\s*$/)
|
|
551
|
+
if (!item) break
|
|
552
|
+
const value = unquoteYaml(item[2])
|
|
553
|
+
if (!replacements.has(value)) {
|
|
554
|
+
output.push(line)
|
|
555
|
+
remaining += 1
|
|
556
|
+
continue
|
|
557
|
+
}
|
|
558
|
+
const next = replacements.get(value)
|
|
559
|
+
if (next === null) continue
|
|
560
|
+
const quoted = /^['"]/.test(item[2]) ? `'${next.replace(/'/g, "''")}'` : renderYamlListItem(next)
|
|
561
|
+
output.push(`${item[1]}${quoted}`)
|
|
562
|
+
remaining += 1
|
|
563
|
+
}
|
|
564
|
+
if (remaining === 0) {
|
|
565
|
+
output.splice(start, output.length - start)
|
|
566
|
+
// Drop blank lines left where the section was.
|
|
567
|
+
while (output.length > 0 && output.at(-1) === '' && lines[end] === '') output.pop()
|
|
568
|
+
}
|
|
569
|
+
return [...output, ...lines.slice(end)].join('\n')
|
|
570
|
+
}
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
lowestPatchedVersion,
|
|
13
13
|
normalizeAdvisories,
|
|
14
14
|
planOverrides,
|
|
15
|
+
planReleaseAgeExcludePrune,
|
|
16
|
+
readWorkspaceList,
|
|
17
|
+
rewriteWorkspaceList,
|
|
15
18
|
upsertWorkspaceOverrides,
|
|
16
19
|
} from './audit-fix-plan.mjs'
|
|
17
20
|
|
|
@@ -285,3 +288,107 @@ test('filterSuppressions keeps only active entries that match the repo findings'
|
|
|
285
288
|
// Expired entries are dropped even when they match.
|
|
286
289
|
assert.doesNotMatch(filterSuppressions(xml, ['GHSA-vfj7-8cjw-p6xm'], Date.parse('2026-12-02T00:00:00Z')), /GHSA-vfj7/)
|
|
287
290
|
})
|
|
291
|
+
|
|
292
|
+
test('planReleaseAgeExcludePrune drops versions that are old enough or no longer installed', () => {
|
|
293
|
+
const now = Date.parse('2026-10-06T12:00:00Z')
|
|
294
|
+
const hoursAgo = (hours) => now - hours * 3600000
|
|
295
|
+
const times = {
|
|
296
|
+
'lefthook@2.1.15': hoursAgo(24 * 30),
|
|
297
|
+
'lefthook@2.1.17': hoursAgo(2),
|
|
298
|
+
'tsx@4.23.9': hoursAgo(24 * 10),
|
|
299
|
+
'fallow@3.31.0': hoursAgo(5),
|
|
300
|
+
}
|
|
301
|
+
const plan = planReleaseAgeExcludePrune(
|
|
302
|
+
[
|
|
303
|
+
'fg-devkit',
|
|
304
|
+
'@fallow-cli/*',
|
|
305
|
+
'lefthook@2.1.15 || 2.1.17',
|
|
306
|
+
'tsx@4.23.9',
|
|
307
|
+
'fallow@3.31.0',
|
|
308
|
+
'gone@1.0.0',
|
|
309
|
+
'x@^1.0.0',
|
|
310
|
+
],
|
|
311
|
+
{
|
|
312
|
+
lockfileVersions: new Map([
|
|
313
|
+
['lefthook', new Set(['2.1.15', '2.1.17'])],
|
|
314
|
+
['tsx', new Set(['4.23.9'])],
|
|
315
|
+
['fallow', new Set(['3.31.0'])],
|
|
316
|
+
['x', new Set(['1.2.0'])],
|
|
317
|
+
]),
|
|
318
|
+
publishTime: (name, version) => times[`${name}@${version}`],
|
|
319
|
+
minimumReleaseAgeMinutes: 1440,
|
|
320
|
+
now,
|
|
321
|
+
},
|
|
322
|
+
)
|
|
323
|
+
assert.deepEqual(
|
|
324
|
+
plan.map(({ entry, next }) => [entry, next]),
|
|
325
|
+
[
|
|
326
|
+
['fg-devkit', 'fg-devkit'],
|
|
327
|
+
['@fallow-cli/*', '@fallow-cli/*'],
|
|
328
|
+
['lefthook@2.1.15 || 2.1.17', 'lefthook@2.1.17'],
|
|
329
|
+
['tsx@4.23.9', null],
|
|
330
|
+
['fallow@3.31.0', 'fallow@3.31.0'],
|
|
331
|
+
['gone@1.0.0', null],
|
|
332
|
+
['x@^1.0.0', 'x@^1.0.0'],
|
|
333
|
+
],
|
|
334
|
+
)
|
|
335
|
+
assert.deepEqual(plan[5].dropped, [{ version: '1.0.0', reason: 'not-installed' }])
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('planReleaseAgeExcludePrune keeps versions with unknown publish time', () => {
|
|
339
|
+
const plan = planReleaseAgeExcludePrune(['private@1.0.0'], {
|
|
340
|
+
lockfileVersions: new Map([['private', new Set(['1.0.0'])]]),
|
|
341
|
+
publishTime: () => undefined,
|
|
342
|
+
minimumReleaseAgeMinutes: 1440,
|
|
343
|
+
})
|
|
344
|
+
assert.equal(plan[0].next, 'private@1.0.0')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('readWorkspaceList and rewriteWorkspaceList edit only the given list', () => {
|
|
348
|
+
const text = [
|
|
349
|
+
'allowBuilds:',
|
|
350
|
+
' esbuild: true',
|
|
351
|
+
'minimumReleaseAgeExclude:',
|
|
352
|
+
' - fg-devkit',
|
|
353
|
+
' - tsx@4.23.9',
|
|
354
|
+
' - lefthook@2.1.15 || 2.1.17',
|
|
355
|
+
" - '@fallow-cli/darwin-arm64@3.31.0'",
|
|
356
|
+
'overrides:',
|
|
357
|
+
" 'a@<1.0.1': '1.0.1'",
|
|
358
|
+
'',
|
|
359
|
+
].join('\n')
|
|
360
|
+
assert.deepEqual(readWorkspaceList(text, 'minimumReleaseAgeExclude'), [
|
|
361
|
+
'fg-devkit',
|
|
362
|
+
'tsx@4.23.9',
|
|
363
|
+
'lefthook@2.1.15 || 2.1.17',
|
|
364
|
+
'@fallow-cli/darwin-arm64@3.31.0',
|
|
365
|
+
])
|
|
366
|
+
const result = rewriteWorkspaceList(
|
|
367
|
+
text,
|
|
368
|
+
'minimumReleaseAgeExclude',
|
|
369
|
+
new Map([
|
|
370
|
+
['tsx@4.23.9', null],
|
|
371
|
+
['lefthook@2.1.15 || 2.1.17', 'lefthook@2.1.17'],
|
|
372
|
+
['@fallow-cli/darwin-arm64@3.31.0', null],
|
|
373
|
+
]),
|
|
374
|
+
)
|
|
375
|
+
assert.equal(
|
|
376
|
+
result,
|
|
377
|
+
[
|
|
378
|
+
'allowBuilds:',
|
|
379
|
+
' esbuild: true',
|
|
380
|
+
'minimumReleaseAgeExclude:',
|
|
381
|
+
' - fg-devkit',
|
|
382
|
+
' - lefthook@2.1.17',
|
|
383
|
+
'overrides:',
|
|
384
|
+
" 'a@<1.0.1': '1.0.1'",
|
|
385
|
+
'',
|
|
386
|
+
].join('\n'),
|
|
387
|
+
)
|
|
388
|
+
const emptied = rewriteWorkspaceList(
|
|
389
|
+
'minimumReleaseAgeExclude:\n - tsx@4.23.9\nallowBuilds:\n esbuild: true\n',
|
|
390
|
+
'minimumReleaseAgeExclude',
|
|
391
|
+
new Map([['tsx@4.23.9', null]]),
|
|
392
|
+
)
|
|
393
|
+
assert.equal(emptied, 'allowBuilds:\n esbuild: true\n')
|
|
394
|
+
})
|
|
@@ -10,11 +10,14 @@
|
|
|
10
10
|
* pnpm-workspace.yaml. Findings with no patch, a patch in another major, a patch
|
|
11
11
|
* younger than minimumReleaseAge, or on a direct dependency are reported, not overridden.
|
|
12
12
|
* 4. Prune overrides that no longer change the audit (prune-overrides --fix).
|
|
13
|
-
* 5.
|
|
13
|
+
* 5. Prune minimumReleaseAgeExclude entries that are no longer needed
|
|
14
|
+
* (prune-release-age-exclude --fix). Runs even when the audit is clean.
|
|
15
|
+
* 6. If findings that are still reported have active <suppress> entries in fg-devkit's
|
|
14
16
|
* shared dependency-check-suppressions.xml, write the repo's copy with only those
|
|
15
17
|
* entries, so OWASP Dependency-Check accepts exactly the risks this repo has.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
+
* 7. Install and run the repo's checks (lint-check, lint:ts, test, test:unit, build).
|
|
19
|
+
* 8. Commit the refresh, the overrides, the release-age excludes and the suppressions
|
|
20
|
+
* file separately.
|
|
18
21
|
*
|
|
19
22
|
* Usage:
|
|
20
23
|
* pnpm exec fg-devkit audit-override-commit [--no-verify] [--no-commit]
|
|
@@ -34,7 +37,9 @@ import {
|
|
|
34
37
|
isReleaseAgeExcluded,
|
|
35
38
|
normalizeAdvisories,
|
|
36
39
|
parseAuditJson,
|
|
40
|
+
parseReleaseAgeExcludeEntry,
|
|
37
41
|
planOverrides,
|
|
42
|
+
readWorkspaceList,
|
|
38
43
|
upsertWorkspaceOverrides,
|
|
39
44
|
} from './audit-fix-plan.mjs'
|
|
40
45
|
import {
|
|
@@ -246,6 +251,44 @@ function writeOverrides(overrides) {
|
|
|
246
251
|
runOrFail('pnpm', ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'])
|
|
247
252
|
}
|
|
248
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Remove minimumReleaseAgeExclude entries that are no longer needed. Returns the
|
|
256
|
+
* entries removed and the narrowed entries added, for the commit message.
|
|
257
|
+
*/
|
|
258
|
+
function pruneReleaseAgeExclude() {
|
|
259
|
+
const before = readWorkspaceList(readFile('pnpm-workspace.yaml') ?? '', 'minimumReleaseAgeExclude')
|
|
260
|
+
if (before.length === 0) return { removed: [], added: [] }
|
|
261
|
+
console.log(`${TAG} Checking minimumReleaseAgeExclude…`)
|
|
262
|
+
const result = run(process.execPath, [path.join(scriptDir, 'prune-release-age-exclude.mjs'), '--fix'])
|
|
263
|
+
if (result.status !== 0) {
|
|
264
|
+
console.warn(`${TAG} Could not prune minimumReleaseAgeExclude; left unchanged.`)
|
|
265
|
+
return { removed: [], added: [] }
|
|
266
|
+
}
|
|
267
|
+
const after = readWorkspaceList(readFile('pnpm-workspace.yaml') ?? '', 'minimumReleaseAgeExclude')
|
|
268
|
+
return {
|
|
269
|
+
removed: before.filter((entry) => !after.includes(entry)),
|
|
270
|
+
added: after.filter((entry) => !before.includes(entry)),
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** `removed entry` or `old entry -> narrowed entry` lines. */
|
|
275
|
+
function describeExcludeChanges({ removed, added }) {
|
|
276
|
+
const nameOf = (entry) => parseReleaseAgeExcludeEntry(entry).name
|
|
277
|
+
return removed.map((entry) => {
|
|
278
|
+
const narrowed = added.find((item) => nameOf(item) === nameOf(entry))
|
|
279
|
+
return narrowed ? `${entry} -> ${narrowed}` : `${entry}`
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function buildReleaseAgeExcludeCommitMessage(changes) {
|
|
284
|
+
return {
|
|
285
|
+
subject: 'chore(repo): prune minimumReleaseAgeExclude',
|
|
286
|
+
body: describeExcludeChanges(changes)
|
|
287
|
+
.map((line) => `- ${line}`)
|
|
288
|
+
.join('\n'),
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
249
292
|
function pruneOverrides() {
|
|
250
293
|
if (!/^overrides:/m.test(readFile('pnpm-workspace.yaml') ?? '')) return
|
|
251
294
|
console.log(`${TAG} Pruning overrides that no longer affect pnpm audit…`)
|
|
@@ -294,7 +337,7 @@ function verify() {
|
|
|
294
337
|
}
|
|
295
338
|
}
|
|
296
339
|
|
|
297
|
-
function printSummary({ fixedByRefresh, plan, heldBack, applied, remaining, suppressions }) {
|
|
340
|
+
function printSummary({ fixedByRefresh, plan, heldBack, applied, remaining, suppressions, excludeChanges }) {
|
|
298
341
|
const suppressed = new Set(suppressions.covered.map((advisory) => advisory.ghsa))
|
|
299
342
|
const unique = (items) => [...new Set(items)]
|
|
300
343
|
const lines = []
|
|
@@ -337,6 +380,7 @@ function printSummary({ fixedByRefresh, plan, heldBack, applied, remaining, supp
|
|
|
337
380
|
'Could not read the patched range, check manually:',
|
|
338
381
|
plan.unparseable.map((item) => `${item.name}: ${item.patched} (${item.ghsas.join(', ')})`),
|
|
339
382
|
)
|
|
383
|
+
section('Removed from minimumReleaseAgeExclude (no longer needed):', describeExcludeChanges(excludeChanges))
|
|
340
384
|
if (remaining.length > 0) {
|
|
341
385
|
lines.push(`Still reported by pnpm audit: ${remaining.length} advisory range(s).`)
|
|
342
386
|
} else {
|
|
@@ -360,60 +404,79 @@ function main() {
|
|
|
360
404
|
|
|
361
405
|
console.log(`${TAG} Running pnpm audit…`)
|
|
362
406
|
const initial = audit()
|
|
407
|
+
let refreshed = original
|
|
408
|
+
let refreshChanges = []
|
|
409
|
+
let fixedByRefresh = []
|
|
410
|
+
let plan = { overrides: [], noPatch: [], crossMajor: [], direct: [], unparseable: [] }
|
|
411
|
+
let heldBack = []
|
|
412
|
+
let allowed = []
|
|
413
|
+
let remaining = []
|
|
414
|
+
|
|
363
415
|
if (initial.length === 0) {
|
|
364
|
-
console.log(`${TAG} pnpm audit is clean
|
|
365
|
-
|
|
416
|
+
console.log(`${TAG} pnpm audit is clean.`)
|
|
417
|
+
} else {
|
|
418
|
+
console.log(`${TAG} ${initial.length} advisory range(s) in ${new Set(initial.map((a) => a.name)).size} package(s).`)
|
|
419
|
+
|
|
420
|
+
// 2. Refresh within ranges.
|
|
421
|
+
refreshLockfile(initial)
|
|
422
|
+
refreshed = snapshot()
|
|
423
|
+
refreshChanges = diffLockfileVersions(original['pnpm-lock.yaml'], refreshed['pnpm-lock.yaml'])
|
|
424
|
+
|
|
425
|
+
// 3. Plan overrides for what is left.
|
|
426
|
+
const afterRefresh = audit()
|
|
427
|
+
const remainingIds = new Set(afterRefresh.map((advisory) => advisory.id))
|
|
428
|
+
fixedByRefresh = [
|
|
429
|
+
...new Set(initial.filter((advisory) => !remainingIds.has(advisory.id)).map((a) => `${a.name} (${a.ghsa})`)),
|
|
430
|
+
]
|
|
431
|
+
plan = planOverrides(afterRefresh, { directDependencies: directDependencies() })
|
|
432
|
+
;({ allowed, heldBack } = applyReleaseAgeGate(plan.overrides))
|
|
433
|
+
if (allowed.length > 0) writeOverrides(allowed)
|
|
434
|
+
|
|
435
|
+
// 4. Prune overrides.
|
|
436
|
+
pruneOverrides()
|
|
437
|
+
remaining = allowed.length > 0 ? audit() : afterRefresh
|
|
366
438
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
const refreshed = snapshot()
|
|
372
|
-
const refreshChanges = diffLockfileVersions(original['pnpm-lock.yaml'], refreshed['pnpm-lock.yaml'])
|
|
373
|
-
|
|
374
|
-
// 3. Plan overrides for what is left.
|
|
375
|
-
const afterRefresh = audit()
|
|
376
|
-
const remainingIds = new Set(afterRefresh.map((advisory) => advisory.id))
|
|
377
|
-
const fixedByRefresh = [
|
|
378
|
-
...new Set(initial.filter((advisory) => !remainingIds.has(advisory.id)).map((a) => `${a.name} (${a.ghsa})`)),
|
|
379
|
-
]
|
|
380
|
-
const plan = planOverrides(afterRefresh, { directDependencies: directDependencies() })
|
|
381
|
-
const { allowed, heldBack } = applyReleaseAgeGate(plan.overrides)
|
|
382
|
-
if (allowed.length > 0) writeOverrides(allowed)
|
|
383
|
-
|
|
384
|
-
// 4. Prune, 5. sync suppressions, 6. verify.
|
|
385
|
-
pruneOverrides()
|
|
386
|
-
const remaining = allowed.length > 0 ? audit() : afterRefresh
|
|
439
|
+
const afterOverrides = snapshot()
|
|
440
|
+
|
|
441
|
+
// 5. Release-age excludes, 6. suppressions, 7. verify.
|
|
442
|
+
const excludeChanges = pruneReleaseAgeExclude()
|
|
387
443
|
const suppressions = syncSuppressions(remaining)
|
|
388
|
-
printSummary({ fixedByRefresh, plan, heldBack, applied: allowed, remaining, suppressions })
|
|
389
|
-
verify()
|
|
444
|
+
printSummary({ fixedByRefresh, plan, heldBack, applied: allowed, remaining, suppressions, excludeChanges })
|
|
390
445
|
|
|
391
|
-
// 7. Commit.
|
|
392
446
|
const final = snapshot()
|
|
393
|
-
const overrideChanges = collectOverrideChanges(overridesBefore, collectOverrides(cwd))
|
|
394
447
|
const nothingChanged = !suppressionsWritten && TRACKED_FILES.every((file) => final[file] === original[file])
|
|
395
448
|
if (nothingChanged) {
|
|
396
449
|
console.log(`${TAG} No file changes.`)
|
|
397
450
|
return 0
|
|
398
451
|
}
|
|
452
|
+
verify()
|
|
453
|
+
|
|
454
|
+
// 8. Commit.
|
|
399
455
|
if (!shouldCommit) {
|
|
400
456
|
console.log(`${TAG} --no-commit: changes left in the working tree.`)
|
|
401
457
|
return 0
|
|
402
458
|
}
|
|
403
|
-
|
|
459
|
+
const overrideChanges = collectOverrideChanges(overridesBefore, collectOverrides(cwd))
|
|
404
460
|
if (refreshChanges.length > 0 && stageContents(refreshed) > 0) {
|
|
405
461
|
commit(buildRefreshCommitMessage(refreshChanges))
|
|
406
462
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
console.log(` - ${item.name}: ${item.from ?? '(missing)'} -> ${formatOverrideValue(item.to)}`)
|
|
410
|
-
}
|
|
463
|
+
for (const item of overrideChanges) {
|
|
464
|
+
console.log(` - ${item.name}: ${item.from ?? '(missing)'} -> ${formatOverrideValue(item.to)}`)
|
|
411
465
|
}
|
|
412
|
-
if (stageContents(
|
|
466
|
+
if (stageContents(afterOverrides) > 0) {
|
|
413
467
|
commit(
|
|
414
468
|
overrideChanges.length > 0
|
|
415
469
|
? buildOverrideCommitMessage(overrideChanges)
|
|
416
|
-
: buildRefreshCommitMessage(
|
|
470
|
+
: buildRefreshCommitMessage(
|
|
471
|
+
diffLockfileVersions(refreshed['pnpm-lock.yaml'], afterOverrides['pnpm-lock.yaml']),
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
if (stageContents(snapshot()) > 0) {
|
|
476
|
+
commit(
|
|
477
|
+
excludeChanges.removed.length > 0 || excludeChanges.added.length > 0
|
|
478
|
+
? buildReleaseAgeExcludeCommitMessage(excludeChanges)
|
|
479
|
+
: buildRefreshCommitMessage(diffLockfileVersions(afterOverrides['pnpm-lock.yaml'], final['pnpm-lock.yaml'])),
|
|
417
480
|
)
|
|
418
481
|
}
|
|
419
482
|
if (suppressionsWritten) {
|
package/scripts/fg-devkit.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* pnpm exec fg-devkit policy-upgrade-commit [--prune-overrides] [ncu-args...]
|
|
19
19
|
* pnpm exec fg-devkit audit-override-commit [--no-verify] [--no-commit]
|
|
20
20
|
* pnpm exec fg-devkit prune-overrides [--fix] [--commit]
|
|
21
|
+
* pnpm exec fg-devkit prune-release-age-exclude [--fix] [--commit]
|
|
21
22
|
* pnpm exec fg-devkit sync-project-configs (VS Code + tooling sync; overwrites config files)
|
|
22
23
|
* pnpm exec fg-devkit sync-tooling-configs (tooling sync only; overwrites config files)
|
|
23
24
|
* pnpm exec fg-devkit sync-dependency-check-suppressions-commit [--all]
|
|
@@ -479,6 +480,7 @@ const DIRECT_COMMANDS = {
|
|
|
479
480
|
'policy-upgrade-commit': 'policy-upgrade-commit.mjs',
|
|
480
481
|
'audit-override-commit': 'audit-override-commit.mjs',
|
|
481
482
|
'prune-overrides': 'prune-overrides.mjs',
|
|
483
|
+
'prune-release-age-exclude': 'prune-release-age-exclude.mjs',
|
|
482
484
|
'sync-project-configs': 'sync-project-configs.mjs',
|
|
483
485
|
'sync-tooling-configs': 'sync-tooling-configs.mjs',
|
|
484
486
|
'sync-dependency-check-suppressions-commit': 'sync-dependency-check-suppressions-commit.mjs',
|
|
@@ -546,6 +548,8 @@ Commands:
|
|
|
546
548
|
Fix pnpm audit findings: refresh lockfile first, override only when a range blocks the fix, verify, auto-commit
|
|
547
549
|
prune-overrides [--fix] [--commit]
|
|
548
550
|
Report unused workspace overrides; --fix removes them; --commit also auto-commits
|
|
551
|
+
prune-release-age-exclude [--fix] [--commit]
|
|
552
|
+
Report minimumReleaseAgeExclude entries that are no longer needed; --fix removes them; --commit also auto-commits
|
|
549
553
|
sync-project-configs VS Code + tooling sync (overwrites config files)
|
|
550
554
|
sync-tooling-configs Tooling sync only (overwrites config files)
|
|
551
555
|
sync-dependency-check-suppressions-commit [--all]
|
|
@@ -975,8 +979,15 @@ async function runInteractiveMenu() {
|
|
|
975
979
|
{ name: 'Sync and auto-commit', value: 'sync-commit' },
|
|
976
980
|
{ name: 'Upgrade package.json packages and auto-commit', value: 'upgrade-commit' },
|
|
977
981
|
{ name: 'Upgrade package.json packages, prune overrides, and auto-commit', value: 'upgrade-commit-prune' },
|
|
978
|
-
{
|
|
982
|
+
{
|
|
983
|
+
name: 'Fix vulnerabilities (refresh, override, prune) and auto-commit',
|
|
984
|
+
value: 'audit-override-commit',
|
|
985
|
+
},
|
|
979
986
|
{ name: 'Prune unused vulnerability overrides and auto-commit', value: 'prune-overrides' },
|
|
987
|
+
{
|
|
988
|
+
name: 'Prune unneeded minimumReleaseAgeExclude entries and auto-commit',
|
|
989
|
+
value: 'prune-release-age-exclude',
|
|
990
|
+
},
|
|
980
991
|
{ name: 'Cancel', value: 'cancel' },
|
|
981
992
|
],
|
|
982
993
|
default: 'check',
|
|
@@ -999,6 +1010,9 @@ async function runInteractiveMenu() {
|
|
|
999
1010
|
if (choice === 'prune-overrides') {
|
|
1000
1011
|
return runSelectedAction(() => runNode('prune-overrides.mjs', ['--commit']))
|
|
1001
1012
|
}
|
|
1013
|
+
if (choice === 'prune-release-age-exclude') {
|
|
1014
|
+
return runSelectedAction(() => runNode('prune-release-age-exclude.mjs', ['--commit']))
|
|
1015
|
+
}
|
|
1002
1016
|
return 1
|
|
1003
1017
|
}
|
|
1004
1018
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Find minimumReleaseAgeExclude entries in pnpm-workspace.yaml that are no longer needed.
|
|
4
|
+
*
|
|
5
|
+
* A pinned version (`name@1.2.3 || 1.2.4`) is only needed while it is resolved in the
|
|
6
|
+
* lockfile and still younger than minimumReleaseAge. Versions that are old enough now, or
|
|
7
|
+
* no longer installed, are removed; an entry with no versions left is removed entirely.
|
|
8
|
+
* Name rules (`fg-devkit`, `@scope/*`) allow every future release on purpose and are kept.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* pnpm exec fg-devkit prune-release-age-exclude # report only
|
|
12
|
+
* pnpm exec fg-devkit prune-release-age-exclude --fix # remove what is not needed
|
|
13
|
+
* pnpm exec fg-devkit prune-release-age-exclude --commit # remove and auto-commit
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { spawnSync } from 'node:child_process'
|
|
19
|
+
import { parseArgs } from 'node:util'
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_MINIMUM_RELEASE_AGE_MINUTES,
|
|
22
|
+
lockfileVersions,
|
|
23
|
+
parseAuditJson,
|
|
24
|
+
planReleaseAgeExcludePrune,
|
|
25
|
+
readWorkspaceList,
|
|
26
|
+
rewriteWorkspaceList,
|
|
27
|
+
} from './audit-fix-plan.mjs'
|
|
28
|
+
|
|
29
|
+
const TAG = '[prune-release-age-exclude]'
|
|
30
|
+
const KEY = 'minimumReleaseAgeExclude'
|
|
31
|
+
const WORKSPACE_FILE = 'pnpm-workspace.yaml'
|
|
32
|
+
const COMMIT_SUBJECT = 'chore(repo): prune minimumReleaseAgeExclude'
|
|
33
|
+
const cwd = process.cwd()
|
|
34
|
+
|
|
35
|
+
const { values: args } = parseArgs({
|
|
36
|
+
options: {
|
|
37
|
+
fix: { type: 'boolean', default: false },
|
|
38
|
+
commit: { type: 'boolean', default: false },
|
|
39
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
40
|
+
},
|
|
41
|
+
strict: true,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if (args.help) {
|
|
45
|
+
console.log(`Usage: pnpm exec fg-devkit prune-release-age-exclude [--fix] [--commit]
|
|
46
|
+
|
|
47
|
+
Reports ${KEY} entries in ${WORKSPACE_FILE} that are no longer needed: pinned
|
|
48
|
+
versions that are older than minimumReleaseAge, or no longer in the lockfile.
|
|
49
|
+
Name rules such as "fg-devkit" or "@scope/*" are always kept.
|
|
50
|
+
|
|
51
|
+
--fix Remove them and re-verify the lockfile
|
|
52
|
+
--commit Remove them and auto-commit`)
|
|
53
|
+
process.exit(0)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function run(command, commandArgs, { capture = true } = {}) {
|
|
57
|
+
const result = spawnSync(command, commandArgs, {
|
|
58
|
+
cwd,
|
|
59
|
+
env: process.env,
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
62
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
63
|
+
})
|
|
64
|
+
if (result.error) throw result.error
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readJsonOutput(command, commandArgs) {
|
|
69
|
+
const result = run(command, commandArgs)
|
|
70
|
+
if (result.status !== 0) return undefined
|
|
71
|
+
try {
|
|
72
|
+
return parseAuditJson(result.stdout)
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function describe(item) {
|
|
79
|
+
const reasons = item.dropped.map(
|
|
80
|
+
({ version, reason }) =>
|
|
81
|
+
`${version} (${reason === 'old-enough' ? 'older than minimumReleaseAge' : 'not in lockfile'})`,
|
|
82
|
+
)
|
|
83
|
+
return item.next === null
|
|
84
|
+
? `${item.entry}: remove, ${reasons.join(', ')}`
|
|
85
|
+
: `${item.entry} -> ${item.next}: drop ${reasons.join(', ')}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function main() {
|
|
89
|
+
const workspacePath = path.join(cwd, WORKSPACE_FILE)
|
|
90
|
+
const lockfilePath = path.join(cwd, 'pnpm-lock.yaml')
|
|
91
|
+
if (!fs.existsSync(workspacePath) || !fs.existsSync(lockfilePath)) {
|
|
92
|
+
console.log(`${TAG} No ${WORKSPACE_FILE} or pnpm-lock.yaml. Nothing to check.`)
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const original = fs.readFileSync(workspacePath, 'utf8')
|
|
97
|
+
const entries = readWorkspaceList(original, KEY)
|
|
98
|
+
if (entries.length === 0) {
|
|
99
|
+
console.log(`${TAG} No ${KEY} entries.`)
|
|
100
|
+
return 0
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const config = readJsonOutput('pnpm', ['config', 'list', '--json']) ?? {}
|
|
104
|
+
const minutes = Number(config.minimumReleaseAge ?? DEFAULT_MINIMUM_RELEASE_AGE_MINUTES)
|
|
105
|
+
const timesByName = new Map()
|
|
106
|
+
const publishTime = (name, version) => {
|
|
107
|
+
if (!timesByName.has(name)) timesByName.set(name, readJsonOutput('pnpm', ['view', name, 'time', '--json']) ?? {})
|
|
108
|
+
const time = Date.parse(timesByName.get(name)[version] ?? '')
|
|
109
|
+
return Number.isFinite(time) ? time : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
console.log(
|
|
113
|
+
`${TAG} Checking ${entries.length} ${KEY} entr${entries.length === 1 ? 'y' : 'ies'} (minimumReleaseAge ${minutes} min)…`,
|
|
114
|
+
)
|
|
115
|
+
const plan = planReleaseAgeExcludePrune(entries, {
|
|
116
|
+
lockfileVersions: lockfileVersions(fs.readFileSync(lockfilePath, 'utf8')),
|
|
117
|
+
publishTime,
|
|
118
|
+
minimumReleaseAgeMinutes: minutes,
|
|
119
|
+
})
|
|
120
|
+
const changes = plan.filter((item) => item.next !== item.entry)
|
|
121
|
+
if (changes.length === 0) {
|
|
122
|
+
console.log(`${TAG} Every entry is still needed.`)
|
|
123
|
+
return 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(`${TAG} Not needed (${changes.length}):`)
|
|
127
|
+
for (const item of changes) console.log(` - ${describe(item)}`)
|
|
128
|
+
|
|
129
|
+
if (!args.fix && !args.commit) {
|
|
130
|
+
console.log(`${TAG} Re-run with --fix to remove them, or --commit to remove and auto-commit.`)
|
|
131
|
+
return 0
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
fs.writeFileSync(
|
|
135
|
+
workspacePath,
|
|
136
|
+
rewriteWorkspaceList(original, KEY, new Map(changes.map((item) => [item.entry, item.next]))),
|
|
137
|
+
)
|
|
138
|
+
// pnpm verifies the lockfile against the release-age policy here; restore on failure.
|
|
139
|
+
const verify = run('pnpm', ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'])
|
|
140
|
+
if (verify.status !== 0) {
|
|
141
|
+
fs.writeFileSync(workspacePath, original)
|
|
142
|
+
console.error(`${TAG} pnpm rejected the lockfile without these entries, restored ${WORKSPACE_FILE}.`)
|
|
143
|
+
console.error((verify.stderr || verify.stdout || '').trim())
|
|
144
|
+
return 1
|
|
145
|
+
}
|
|
146
|
+
console.log(`${TAG} Updated ${WORKSPACE_FILE}.`)
|
|
147
|
+
|
|
148
|
+
if (!args.commit) return 0
|
|
149
|
+
const files = [WORKSPACE_FILE, 'pnpm-lock.yaml'].filter((file) => fs.existsSync(path.join(cwd, file)))
|
|
150
|
+
if (run('git', ['add', '--', ...files], { capture: false }).status !== 0) return 1
|
|
151
|
+
const body = changes.map((item) => `- ${describe(item)}`).join('\n')
|
|
152
|
+
console.log(`${TAG} Auto-committing: ${COMMIT_SUBJECT}`)
|
|
153
|
+
return run('git', ['commit', '-m', COMMIT_SUBJECT, '-m', body], { capture: false }).status ?? 1
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
process.exit(main())
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error(`${TAG} ${error instanceof Error ? error.message : error}`)
|
|
160
|
+
process.exit(2)
|
|
161
|
+
}
|