fg-devkit 1.20.2 → 1.21.1

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.
@@ -1,16 +1,42 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Fix one or more vulnerabilities via `pnpm audit --fix override -i`, prune unused
4
- * overrides, install, then auto-commit.
3
+ * Fix `pnpm audit` findings with the smallest possible change, verify, and auto-commit.
4
+ *
5
+ * 1. Audit and collect the vulnerable packages.
6
+ * 2. Refresh them in the lockfile (`pnpm update <names>`): newest version each parent's
7
+ * range already allows. No overrides, nothing to clean up later.
8
+ * 3. Audit again. Remaining findings with a patched version that a parent's range blocks
9
+ * get a tight override (`name@>=installed <patched`: lowest patched version) in
10
+ * pnpm-workspace.yaml. Findings with no patch, a patch in another major, a patch
11
+ * younger than minimumReleaseAge, or on a direct dependency are reported, not overridden.
12
+ * 4. Prune overrides that no longer change the audit (prune-overrides --fix).
13
+ * 5. If findings that are still reported have active <suppress> entries in fg-devkit's
14
+ * shared dependency-check-suppressions.xml, write the repo's copy with only those
15
+ * entries, so OWASP Dependency-Check accepts exactly the risks this repo has.
16
+ * 6. Install and run the repo's checks (lint-check, lint:ts, test, test:unit, build).
17
+ * 7. Commit the refresh, the override changes and the suppressions file separately.
5
18
  *
6
19
  * Usage:
7
- * pnpm exec fg-devkit audit-override-commit [pnpm-audit-args...]
20
+ * pnpm exec fg-devkit audit-override-commit [--no-verify] [--no-commit]
8
21
  */
9
22
 
10
23
  import fs from 'node:fs'
11
24
  import path from 'node:path'
12
25
  import { spawnSync } from 'node:child_process'
13
26
  import { fileURLToPath } from 'node:url'
27
+ import { parseArgs } from 'node:util'
28
+ import {
29
+ DEFAULT_MINIMUM_RELEASE_AGE_MINUTES,
30
+ activeSuppressedGhsas,
31
+ buildRefreshCommitMessage,
32
+ filterSuppressions,
33
+ diffLockfileVersions,
34
+ isReleaseAgeExcluded,
35
+ normalizeAdvisories,
36
+ parseAuditJson,
37
+ planOverrides,
38
+ upsertWorkspaceOverrides,
39
+ } from './audit-fix-plan.mjs'
14
40
  import {
15
41
  buildOverrideCommitMessage,
16
42
  collectOverrideChanges,
@@ -18,120 +44,393 @@ import {
18
44
  formatOverrideValue,
19
45
  } from './override-changes.mjs'
20
46
 
47
+ const TAG = '[audit-override-commit]'
21
48
  const cwd = process.cwd()
22
49
  const scriptDir = path.dirname(fileURLToPath(import.meta.url))
23
- const packageJsonPath = path.join(cwd, 'package.json')
50
+ const TRACKED_FILES = ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml']
51
+ const VERIFY_SCRIPTS = ['lint-check', 'lint:ts', 'test', 'test:unit', 'build']
52
+ const SUPPRESSIONS_FILE = 'dependency-check-suppressions.xml'
53
+ const SUPPRESSIONS_COMMIT_MESSAGE = 'chore(repo): sync dependency-check suppressions'
54
+ let suppressionsWritten = false
55
+
56
+ const { values: args } = parseArgs({
57
+ options: {
58
+ 'no-verify': { type: 'boolean', default: false },
59
+ 'no-commit': { type: 'boolean', default: false },
60
+ help: { type: 'boolean', short: 'h', default: false },
61
+ },
62
+ strict: true,
63
+ })
64
+
65
+ if (args.help) {
66
+ console.log(`Usage: pnpm exec fg-devkit audit-override-commit [--no-verify] [--no-commit]
67
+
68
+ Refreshes vulnerable packages in the lockfile within their existing ranges, adds a
69
+ tight override only where a parent's range blocks the patched version, prunes unused
70
+ overrides, syncs dependency-check-suppressions.xml when a remaining finding is
71
+ suppressed there, runs the repo's checks, and commits each kind of change separately.
72
+
73
+ --no-verify Skip lint/typecheck/test/build (git hooks still run on commit)
74
+ --no-commit Leave the changes in the working tree`)
75
+ process.exit(0)
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Process helpers
80
+ // ---------------------------------------------------------------------------
24
81
 
25
- function runPnpm(args) {
26
- const result = spawnSync('pnpm', args, {
27
- stdio: 'inherit',
82
+ function run(command, commandArgs, { capture = false, env = {}, input } = {}) {
83
+ const result = spawnSync(command, commandArgs, {
28
84
  cwd,
29
- env: process.env,
85
+ env: { ...process.env, ...env },
86
+ encoding: 'utf8',
87
+ stdio: capture ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'inherit', 'inherit'],
88
+ input,
89
+ maxBuffer: 50 * 1024 * 1024,
30
90
  })
31
- if (result.error) {
32
- console.error('[audit-override-commit]', result.error.message)
33
- return 1
91
+ if (result.error) throw result.error
92
+ return result
93
+ }
94
+
95
+ function runOrFail(command, commandArgs, options) {
96
+ const result = run(command, commandArgs, options)
97
+ if (result.status !== 0) {
98
+ const detail = options?.capture ? `\n${(result.stderr || result.stdout || '').trim()}` : ''
99
+ throw new Error(`${command} ${commandArgs.join(' ')} failed (exit ${result.status})${detail}`)
34
100
  }
35
- return result.status ?? 1
101
+ return result
36
102
  }
37
103
 
38
- function runGit(args) {
39
- const result = spawnSync('git', args, {
40
- stdio: 'inherit',
41
- cwd,
42
- env: process.env,
43
- })
44
- if (result.error) {
45
- console.error('[audit-override-commit]', result.error.message)
46
- return 1
104
+ function readFile(file) {
105
+ const full = path.join(cwd, file)
106
+ return fs.existsSync(full) ? fs.readFileSync(full, 'utf8') : undefined
107
+ }
108
+
109
+ function snapshot() {
110
+ return Object.fromEntries(TRACKED_FILES.map((file) => [file, readFile(file)]))
111
+ }
112
+
113
+ function isWorkspaceWithPackages() {
114
+ return /^packages:/m.test(readFile('pnpm-workspace.yaml') ?? '')
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // pnpm
119
+ // ---------------------------------------------------------------------------
120
+
121
+ function audit() {
122
+ const result = run('pnpm', ['audit', '--json'], { capture: true })
123
+ if (result.status !== 0 && result.status !== 1) {
124
+ throw new Error(`pnpm audit failed (exit ${result.status})\n${(result.stderr || result.stdout || '').trim()}`)
47
125
  }
48
- return result.status ?? 1
126
+ return normalizeAdvisories(parseAuditJson(result.stdout))
49
127
  }
50
128
 
51
- function runPruneOverridesFix() {
52
- console.log('[audit-override-commit] Pruning overrides that no longer affect pnpm audit…')
53
- const result = spawnSync(process.execPath, [path.join(scriptDir, 'prune-overrides.mjs'), '--fix'], {
54
- stdio: 'inherit',
55
- cwd,
56
- env: process.env,
57
- })
58
- if (result.error) {
59
- console.error('[audit-override-commit]', result.error.message)
60
- return 1
129
+ function readPnpmConfig() {
130
+ const result = run('pnpm', ['config', 'list', '--json'], { capture: true })
131
+ if (result.status !== 0) return {}
132
+ try {
133
+ return parseAuditJson(result.stdout)
134
+ } catch {
135
+ return {}
136
+ }
137
+ }
138
+
139
+ function publishTimes(name) {
140
+ const result = run('pnpm', ['view', name, 'time', '--json'], { capture: true })
141
+ if (result.status !== 0) return {}
142
+ try {
143
+ return parseAuditJson(result.stdout)
144
+ } catch {
145
+ return {}
61
146
  }
62
- return result.status ?? 1
63
147
  }
64
148
 
65
- function stageExistingFiles(files) {
66
- const existingFiles = files.filter((filePath) => fs.existsSync(path.join(cwd, filePath)))
67
- if (existingFiles.length === 0) return 0
68
- return runGit(['add', '--', ...existingFiles])
149
+ function directDependencies() {
150
+ const pkg = JSON.parse(readFile('package.json'))
151
+ return new Set(
152
+ ['dependencies', 'devDependencies', 'optionalDependencies'].flatMap((section) => Object.keys(pkg[section] ?? {})),
153
+ )
69
154
  }
70
155
 
71
- function main() {
72
- if (!fs.existsSync(packageJsonPath)) {
73
- console.error(`[audit-override-commit] package.json not found at ${packageJsonPath}`)
156
+ // ---------------------------------------------------------------------------
157
+ // git
158
+ // ---------------------------------------------------------------------------
159
+
160
+ function assertCleanGitState() {
161
+ const files = [...TRACKED_FILES, SUPPRESSIONS_FILE]
162
+ const status = runOrFail('git', ['status', '--porcelain', '--', ...files], { capture: true }).stdout.trim()
163
+ const problem = status
164
+ ? `Commit or stash your changes to ${files.join(', ')} first:\n${status}`
165
+ : run('git', ['diff', '--cached', '--quiet'], { capture: true }).status !== 0
166
+ ? 'There are staged changes. Commit or unstage them first.'
167
+ : undefined
168
+ if (problem) {
169
+ console.error(`${TAG} ${problem}`)
74
170
  process.exit(1)
75
171
  }
172
+ }
76
173
 
77
- let beforeOverrides
78
- try {
79
- beforeOverrides = collectOverrides(cwd)
80
- } catch (err) {
81
- console.error(`[audit-override-commit] Failed to read overrides before audit fix: ${err.message}`)
82
- process.exit(1)
174
+ function headContent(file) {
175
+ const result = run('git', ['show', `HEAD:${file}`], { capture: true })
176
+ return result.status === 0 ? result.stdout : undefined
177
+ }
178
+
179
+ /** Stage given file contents (not necessarily what is on disk) without touching the working tree. */
180
+ function stageContents(contents) {
181
+ let staged = 0
182
+ for (const [file, content] of Object.entries(contents)) {
183
+ if (content === undefined || content === headContent(file)) continue
184
+ const sha = runOrFail('git', ['hash-object', '-w', '--stdin', `--path=${file}`], {
185
+ capture: true,
186
+ input: content,
187
+ }).stdout.trim()
188
+ runOrFail('git', ['update-index', '--add', '--cacheinfo', `100644,${sha},${file}`])
189
+ staged += 1
83
190
  }
191
+ return staged
192
+ }
193
+
194
+ let commitsMade = 0
195
+
196
+ function commit({ subject, body }) {
197
+ commitsMade += 1
198
+ console.log(`${TAG} Committing: ${subject}`)
199
+ runOrFail('git', body ? ['commit', '-m', subject, '-m', body] : ['commit', '-m', subject])
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Steps
204
+ // ---------------------------------------------------------------------------
84
205
 
85
- const auditArgs =
86
- process.argv.length > 2 ? process.argv.slice(2) : ['audit', '--fix', 'override', '-i']
206
+ function refreshLockfile(advisories) {
207
+ const names = [...new Set(advisories.map((advisory) => advisory.name))].sort()
208
+ console.log(`${TAG} Refreshing within existing ranges: ${names.join(', ')}`)
209
+ const recursive = isWorkspaceWithPackages() ? ['--recursive'] : []
210
+ runOrFail('pnpm', ['update', ...recursive, ...names, '--lockfile-only'])
211
+ }
87
212
 
88
- const auditExitCode = runPnpm(auditArgs)
89
- if (auditExitCode !== 0) {
90
- process.exit(auditExitCode)
213
+ /** Split planned overrides into ones we apply and ones held back by minimumReleaseAge. */
214
+ function applyReleaseAgeGate(overrides) {
215
+ const config = readPnpmConfig()
216
+ const minutes = Number(config.minimumReleaseAge ?? DEFAULT_MINIMUM_RELEASE_AGE_MINUTES)
217
+ if (!minutes) return { allowed: overrides, heldBack: [] }
218
+ const excluded = config.minimumReleaseAgeExclude ?? []
219
+ const allowed = []
220
+ const heldBack = []
221
+ const timesByName = new Map()
222
+ for (const override of overrides) {
223
+ if (isReleaseAgeExcluded(excluded, override.name, override.value)) {
224
+ allowed.push(override)
225
+ continue
226
+ }
227
+ if (!timesByName.has(override.name)) timesByName.set(override.name, publishTimes(override.name))
228
+ const published = Date.parse(timesByName.get(override.name)[override.value] ?? '')
229
+ const ageMinutes = (Date.now() - published) / 60000
230
+ if (Number.isFinite(ageMinutes) && ageMinutes < minutes) {
231
+ heldBack.push({ ...override, waitHours: Math.ceil((minutes - ageMinutes) / 60) })
232
+ } else {
233
+ allowed.push(override)
234
+ }
91
235
  }
236
+ return { allowed, heldBack }
237
+ }
92
238
 
93
- const installExitCode = runPnpm(['install'])
94
- if (installExitCode !== 0) {
95
- process.exit(installExitCode)
239
+ function writeOverrides(overrides) {
240
+ const file = path.join(cwd, 'pnpm-workspace.yaml')
241
+ const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
242
+ fs.writeFileSync(file, upsertWorkspaceOverrides(before, overrides))
243
+ for (const override of overrides) {
244
+ console.log(`${TAG} Override ${override.key}: ${override.value} (${override.ghsas.join(', ')})`)
96
245
  }
246
+ runOrFail('pnpm', ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'])
247
+ }
248
+
249
+ function pruneOverrides() {
250
+ if (!/^overrides:/m.test(readFile('pnpm-workspace.yaml') ?? '')) return
251
+ console.log(`${TAG} Pruning overrides that no longer affect pnpm audit…`)
252
+ runOrFail(process.execPath, [path.join(scriptDir, 'prune-overrides.mjs'), '--fix'])
253
+ }
97
254
 
98
- const pruneExitCode = runPruneOverridesFix()
99
- if (pruneExitCode !== 0) {
100
- process.exit(pruneExitCode)
255
+ /**
256
+ * Write the repo's dependency-check-suppressions.xml with only the entries from fg-devkit's
257
+ * shared file that match a finding still reported here. Untouched when nothing matches.
258
+ */
259
+ function syncSuppressions(remaining) {
260
+ const sourcePath = path.resolve(scriptDir, '..', 'configs', SUPPRESSIONS_FILE)
261
+ if (!fs.existsSync(sourcePath)) return { covered: [], uncovered: remaining }
262
+ const shared = fs.readFileSync(sourcePath, 'utf8')
263
+ const active = activeSuppressedGhsas(shared)
264
+ const covered = remaining
265
+ .filter((advisory) => active.has(advisory.ghsa))
266
+ .map((advisory) => ({ ...advisory, until: active.get(advisory.ghsa) }))
267
+ const uncovered = remaining.filter((advisory) => !active.has(advisory.ghsa))
268
+ if (covered.length === 0) return { covered, uncovered }
269
+ const content = filterSuppressions(
270
+ shared,
271
+ covered.map((advisory) => advisory.ghsa),
272
+ )
273
+ if (readFile(SUPPRESSIONS_FILE) !== content) {
274
+ fs.writeFileSync(path.join(cwd, SUPPRESSIONS_FILE), content)
275
+ suppressionsWritten = true
276
+ console.log(`${TAG} Wrote ${SUPPRESSIONS_FILE} with the entries that apply to this repo.`)
101
277
  }
278
+ return { covered, uncovered }
279
+ }
102
280
 
103
- let afterOverrides
104
- try {
105
- afterOverrides = collectOverrides(cwd)
106
- } catch (err) {
107
- console.error(`[audit-override-commit] Failed to read overrides after audit fix: ${err.message}`)
108
- process.exit(1)
281
+ function verify() {
282
+ console.log(`${TAG} Installing…`)
283
+ runOrFail('pnpm', ['install', '--no-frozen-lockfile'])
284
+ if (args['no-verify']) return
285
+ const scripts = JSON.parse(readFile('package.json')).scripts ?? {}
286
+ const toRun = VERIFY_SCRIPTS.filter((name) => scripts[name])
287
+ if (toRun.length === 0) {
288
+ console.log(`${TAG} No verification scripts (${VERIFY_SCRIPTS.join(', ')}) found; skipping checks.`)
289
+ return
109
290
  }
291
+ for (const name of toRun) {
292
+ console.log(`${TAG} Verifying: pnpm run ${name}`)
293
+ runOrFail('pnpm', ['run', name], { env: { CI: 'true' } })
294
+ }
295
+ }
110
296
 
111
- const changed = collectOverrideChanges(beforeOverrides, afterOverrides)
112
- if (changed.length === 0) {
113
- console.log('[audit-override-commit] No override changes detected.')
114
- process.exit(0)
297
+ function printSummary({ fixedByRefresh, plan, heldBack, applied, remaining, suppressions }) {
298
+ const suppressed = new Set(suppressions.covered.map((advisory) => advisory.ghsa))
299
+ const unique = (items) => [...new Set(items)]
300
+ const lines = []
301
+ const section = (title, items) => {
302
+ if (items.length === 0) return
303
+ lines.push(title, ...items.map((item) => ` - ${item}`))
115
304
  }
305
+ section('Fixed by lockfile refresh:', fixedByRefresh)
306
+ section(
307
+ 'Overridden (parent range blocked the patch):',
308
+ applied.map((item) => `${item.key} -> ${item.value} (${item.ghsas.join(', ')})`),
309
+ )
310
+ section(
311
+ 'Held back by minimumReleaseAge (re-run later, or add to minimumReleaseAgeExclude):',
312
+ heldBack.map((item) => `${item.name}@${item.value}, ~${item.waitHours}h left (${item.ghsas.join(', ')})`),
313
+ )
314
+ section(
315
+ 'Direct dependency, bump it in package.json (policy-upgrade-commit):',
316
+ plan.direct.map((item) => `${item.name} ${item.versions.join(', ')} (${item.ghsas.join(', ')})`),
317
+ )
318
+ section(
319
+ 'Patch only in a new major, upgrade the parent instead:',
320
+ plan.crossMajor.map((item) => `${item.name} ${item.from} -> ${item.to} (${item.ghsas.join(', ')})`),
321
+ )
322
+ section(
323
+ 'No patched version and not suppressed, add a <suppress> entry to fg-devkit configs/dependency-check-suppressions.xml if the risk is accepted:',
324
+ plan.noPatch
325
+ .filter((item) => !item.ghsas.every((ghsa) => suppressed.has(ghsa)))
326
+ .map((item) => `${item.name} ${item.versions.join(', ')} (${item.ghsas.join(', ')})`),
327
+ )
328
+ section(
329
+ `Accepted in ${SUPPRESSIONS_FILE}${suppressionsWritten ? ' (updated from fg-devkit)' : ' (already up to date)'}:`,
330
+ unique(
331
+ suppressions.covered.map(
332
+ (advisory) => `${advisory.name} ${advisory.ghsa}${advisory.until ? `, until ${advisory.until}` : ''}`,
333
+ ),
334
+ ),
335
+ )
336
+ section(
337
+ 'Could not read the patched range, check manually:',
338
+ plan.unparseable.map((item) => `${item.name}: ${item.patched} (${item.ghsas.join(', ')})`),
339
+ )
340
+ if (remaining.length > 0) {
341
+ lines.push(`Still reported by pnpm audit: ${remaining.length} advisory range(s).`)
342
+ } else {
343
+ lines.push('pnpm audit is clean.')
344
+ }
345
+ console.log(`\n${TAG} Summary\n${lines.join('\n')}`)
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Main
350
+ // ---------------------------------------------------------------------------
351
+
352
+ function main() {
353
+ if (!readFile('package.json')) throw new Error(`package.json not found in ${cwd}`)
354
+ if (!readFile('pnpm-lock.yaml')) throw new Error(`pnpm-lock.yaml not found in ${cwd}`)
355
+ const shouldCommit = !args['no-commit']
356
+ if (shouldCommit) assertCleanGitState()
357
+
358
+ const original = snapshot()
359
+ const overridesBefore = collectOverrides(cwd)
116
360
 
117
- console.log('[audit-override-commit] Override changes detected:')
118
- for (const item of changed) {
119
- const fromLabel = item.from ?? '(missing)'
120
- console.log(` - ${item.name}: ${fromLabel} -> ${formatOverrideValue(item.to)}`)
361
+ console.log(`${TAG} Running pnpm audit…`)
362
+ const initial = audit()
363
+ if (initial.length === 0) {
364
+ console.log(`${TAG} pnpm audit is clean. Nothing to do.`)
365
+ return 0
121
366
  }
367
+ console.log(`${TAG} ${initial.length} advisory range(s) in ${new Set(initial.map((a) => a.name)).size} package(s).`)
122
368
 
123
- const stageExitCode = stageExistingFiles(['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'])
124
- if (stageExitCode !== 0) {
125
- process.exit(stageExitCode)
369
+ // 2. Refresh within ranges.
370
+ refreshLockfile(initial)
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
387
+ const suppressions = syncSuppressions(remaining)
388
+ printSummary({ fixedByRefresh, plan, heldBack, applied: allowed, remaining, suppressions })
389
+ verify()
390
+
391
+ // 7. Commit.
392
+ const final = snapshot()
393
+ const overrideChanges = collectOverrideChanges(overridesBefore, collectOverrides(cwd))
394
+ const nothingChanged = !suppressionsWritten && TRACKED_FILES.every((file) => final[file] === original[file])
395
+ if (nothingChanged) {
396
+ console.log(`${TAG} No file changes.`)
397
+ return 0
398
+ }
399
+ if (!shouldCommit) {
400
+ console.log(`${TAG} --no-commit: changes left in the working tree.`)
401
+ return 0
126
402
  }
127
403
 
128
- const { subject, body } = buildOverrideCommitMessage(changed)
129
- console.log(`[audit-override-commit] Auto-committing: ${subject}`)
130
- if (body) {
131
- console.log(body)
404
+ if (refreshChanges.length > 0 && stageContents(refreshed) > 0) {
405
+ commit(buildRefreshCommitMessage(refreshChanges))
132
406
  }
133
- const commitArgs = body ? ['commit', '-m', subject, '-m', body] : ['commit', '-m', subject]
134
- process.exit(runGit(commitArgs))
407
+ if (overrideChanges.length > 0) {
408
+ for (const item of overrideChanges) {
409
+ console.log(` - ${item.name}: ${item.from ?? '(missing)'} -> ${formatOverrideValue(item.to)}`)
410
+ }
411
+ }
412
+ if (stageContents(final) > 0) {
413
+ commit(
414
+ overrideChanges.length > 0
415
+ ? buildOverrideCommitMessage(overrideChanges)
416
+ : buildRefreshCommitMessage(diffLockfileVersions(refreshed['pnpm-lock.yaml'], final['pnpm-lock.yaml'])),
417
+ )
418
+ }
419
+ if (suppressionsWritten) {
420
+ runOrFail('git', ['add', '--', SUPPRESSIONS_FILE])
421
+ commit({ subject: SUPPRESSIONS_COMMIT_MESSAGE })
422
+ }
423
+ return 0
135
424
  }
136
425
 
137
- main()
426
+ try {
427
+ process.exit(main())
428
+ } catch (error) {
429
+ console.error(`${TAG} ${error instanceof Error ? error.message : error}`)
430
+ console.error(
431
+ commitsMade > 0
432
+ ? `${TAG} ${commitsMade} commit(s) were made before the failure. Check git log and git status.`
433
+ : `${TAG} Nothing was committed. Inspect the changes with git status and git diff.`,
434
+ )
435
+ process.exit(1)
436
+ }
@@ -33,10 +33,7 @@ import fs from 'node:fs'
33
33
  import path from 'node:path'
34
34
  import { fileURLToPath } from 'node:url'
35
35
 
36
- import {
37
- DEFAULT_NODE_TOOLCHAIN_EXCLUDE,
38
- collectFleetProjectRepos,
39
- } from './fleet-project-repos.mjs'
36
+ import { collectFleetProjectRepos, toolchainExclude } from './fleet-project-repos.mjs'
40
37
  import { parseFleetSelectionFlags, resolveFleetRepos } from './fleet-repo-picker.mjs'
41
38
  import {
42
39
  formatNodeNvmrc,
@@ -213,7 +210,7 @@ async function main() {
213
210
  process.exit(1)
214
211
  }
215
212
 
216
- const allRepos = collectFleetProjectRepos(projectsRoot, { exclude: DEFAULT_NODE_TOOLCHAIN_EXCLUDE })
213
+ const allRepos = collectFleetProjectRepos(projectsRoot, { exclude: toolchainExclude(projectsRoot) })
217
214
  if (allRepos.length === 0) {
218
215
  console.log(`[bump-node-toolchain] No project repositories under ${projectsRoot}`)
219
216
  process.exit(0)
@@ -16,11 +16,11 @@
16
16
  * pnpm exec fg-devkit bump-node-toolchain [nvmrc-version] [projects-root] [--dry-run] [--skip-fleet] [--no-engines]
17
17
  * pnpm exec fg-devkit policy-upgrade [policy-file] [ncu-args...]
18
18
  * pnpm exec fg-devkit policy-upgrade-commit [--prune-overrides] [ncu-args...]
19
- * pnpm exec fg-devkit audit-override-commit [pnpm-audit-args...]
19
+ * pnpm exec fg-devkit audit-override-commit [--no-verify] [--no-commit]
20
20
  * pnpm exec fg-devkit prune-overrides [--fix] [--commit]
21
21
  * pnpm exec fg-devkit sync-project-configs (VS Code + tooling sync; overwrites config files)
22
22
  * pnpm exec fg-devkit sync-tooling-configs (tooling sync only; overwrites config files)
23
- * pnpm exec fg-devkit sync-dependency-check-suppressions-commit
23
+ * pnpm exec fg-devkit sync-dependency-check-suppressions-commit [--all]
24
24
  * pnpm exec fg-devkit sync-vscode-settings
25
25
  * pnpm exec fg-devkit sync-core-coding-standards
26
26
  * pnpm exec fg-devkit sync-consumer-scripts
@@ -542,14 +542,14 @@ Commands:
542
542
  policy-upgrade-commit [--prune-overrides] [ncu-args...]
543
543
  Upgrade consumer package.json packages and auto-commit
544
544
  --prune-overrides also drops unused workspace overrides in a second commit
545
- audit-override-commit [pnpm-audit-args...]
546
- Fix vulnerability overrides via pnpm audit, prune unused ones, auto-commit
545
+ audit-override-commit [--no-verify] [--no-commit]
546
+ Fix pnpm audit findings: refresh lockfile first, override only when a range blocks the fix, verify, auto-commit
547
547
  prune-overrides [--fix] [--commit]
548
548
  Report unused workspace overrides; --fix removes them; --commit also auto-commits
549
549
  sync-project-configs VS Code + tooling sync (overwrites config files)
550
550
  sync-tooling-configs Tooling sync only (overwrites config files)
551
- sync-dependency-check-suppressions-commit
552
- Copy dependency-check-suppressions.xml and auto-commit
551
+ sync-dependency-check-suppressions-commit [--all]
552
+ Write the dependency-check suppressions that match pnpm audit findings and auto-commit (--all: copy every entry)
553
553
  sync-vscode-settings Copy shared VS Code settings into .vscode/settings.json
554
554
  sync-core-coding-standards Copy .cursor core coding standards with consumer package name
555
555
  sync-vite-plugin-vue-inspector Add/pin vite-plugin-vue-inspector, wire vite.config, install, commit
@@ -80,11 +80,31 @@ export function collectFleetGitRepos(projectsRoot) {
80
80
  return repos.sort((a, b) => a.localeCompare(b))
81
81
  }
82
82
 
83
- /** Repos that intentionally maintain multiple Node versions or non-pnpm tooling. */
84
- export const DEFAULT_NODE_TOOLCHAIN_EXCLUDE = new Set(['jenkins-dockerfile', 'vulnerability-test'])
83
+ /** Local config file in the projects root (not part of fg-devkit). */
84
+ export const FLEET_CONFIG_FILE = '.fg-devkit-fleet.json'
85
85
 
86
- /** Repos excluded from fleet-wide pnpm toolchain bumps (non-pnpm or special tooling). */
87
- export const DEFAULT_PNPM_TOOLCHAIN_EXCLUDE = new Set(['jenkins-dockerfile', 'vulnerability-test'])
86
+ /**
87
+ * Repos excluded from fleet-wide Node and pnpm toolchain bumps (for example repos that
88
+ * maintain several Node versions or do not use pnpm). Read from `toolchainExclude` in
89
+ * `<projectsRoot>/.fg-devkit-fleet.json` and the comma-separated `FGD_TOOLCHAIN_EXCLUDE`
90
+ * environment variable.
91
+ *
92
+ * @param {string} projectsRoot
93
+ * @returns {Set<string>}
94
+ */
95
+ export function toolchainExclude(projectsRoot) {
96
+ const names = new Set()
97
+ try {
98
+ const config = JSON.parse(fs.readFileSync(path.join(projectsRoot, FLEET_CONFIG_FILE), 'utf8'))
99
+ for (const name of config?.toolchainExclude ?? []) names.add(String(name))
100
+ } catch {
101
+ // No local config: nothing excluded.
102
+ }
103
+ for (const name of (process.env.FGD_TOOLCHAIN_EXCLUDE ?? '').split(',')) {
104
+ if (name.trim()) names.add(name.trim())
105
+ }
106
+ return names
107
+ }
88
108
 
89
109
  /**
90
110
  * @param {string} dir
@@ -108,7 +128,7 @@ export function isPnpmPackageJsonDir(dir, pkg) {
108
128
  export function collectFleetPnpmRepos(projectsRoot, options = {}) {
109
129
  const exclude = new Set([
110
130
  ...DEFAULT_FLEET_REPO_EXCLUDE,
111
- ...DEFAULT_PNPM_TOOLCHAIN_EXCLUDE,
131
+ ...toolchainExclude(projectsRoot),
112
132
  ...(options.exclude ?? []),
113
133
  ])
114
134
  const { skipScaffoldVue = true } = options
@@ -13,7 +13,7 @@ import {
13
13
  } from './dependency-policy-toolchain.mjs'
14
14
 
15
15
  const DOCKER_FROM_NODE_PATTERN = /FROM node:(v)?(\d+(?:\.\d+)?)(-[a-z0-9-]+)?/gi
16
- const JENKINS_NODE_IMAGE_PATTERN = /(feelgoodsvenska\/jenkins-build:)node\d+/g
16
+ const JENKINS_NODE_IMAGE_PATTERN = /([\w.-]+\/jenkins-build:)node\d+/g
17
17
  const GITHUB_NODE_VERSION_PATTERN = /node-version:\s*['"]?\d+(?:\.\d+)?['"]?/g
18
18
 
19
19
  /**
@@ -323,7 +323,7 @@ export function collectNodeToolchainViolations(repoRoot, policy, fgDevkitRoot) {
323
323
  const expectedJenkins = jenkinsImage ?? deriveJenkinsImageTag(nvmrc)
324
324
  if (fs.existsSync(jenkinsfilePath)) {
325
325
  const jenkinsfile = fs.readFileSync(jenkinsfilePath, 'utf8')
326
- const matches = [...jenkinsfile.matchAll(/feelgoodsvenska\/jenkins-build:(node\d+)/g)]
326
+ const matches = [...jenkinsfile.matchAll(/[\w.-]+\/jenkins-build:(node\d+)/g)]
327
327
  for (const match of matches) {
328
328
  if (match[1] !== expectedJenkins) {
329
329
  violations.push(`Jenkinsfile: expected jenkins-build:${expectedJenkins}, got jenkins-build:${match[1]}`)
@@ -381,20 +381,3 @@ test('pull plans tracking id updates from Piwik into the local map', () => {
381
381
  'export const customDimensions = { active: 46, step: 30 } as const\n',
382
382
  )
383
383
  })
384
-
385
- test('parses the real consumer dimension files when they are checked out beside fg-devkit', () => {
386
- const files = [
387
- ['../../bokningsverktyget-ui/src/analytics/dimensions.ts', 'event', 'rowsBooked', 7],
388
- ['../../webbportalen-ui/src/analytics/dimensions.ts', 'session', 'organizationName', 26],
389
- ['../../feelgood-app/src/util/piwikDimensions.ts', 'event', 'eventTimestamp', 78],
390
- ]
391
- for (const [relative, scope, key, slot] of files) {
392
- const file = path.join(scriptDir, relative)
393
- if (!fs.existsSync(file)) continue
394
- const dimensions = parseDimensionSource(fs.readFileSync(file, 'utf8'), relative)
395
- const found = dimensions.find((item) => item.key === key)
396
- assert.ok(found, `${key} missing in ${relative}`)
397
- assert.equal(found.scope, scope)
398
- assert.equal(found.slot, slot)
399
- }
400
- })