fg-devkit 1.16.2 → 1.17.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.
@@ -67,6 +67,8 @@ alwaysApply: true
67
67
  - Set `staleTime` intentionally per resource; keep `refetchOnWindowFocus` off unless fresh-on-focus behavior is required.
68
68
  - On mutation success, update cache intentionally (`setQueryData`) and/or invalidate the narrowest matching keys.
69
69
  - Respect shared query client retry behavior and only override retries when endpoint-specific behavior is needed.
70
+ - TanStack Query boolean flags (`isPending`, `isSuccess`, `isError`) are typed `Ref<false> | Ref<true>`. `vue-tsc` unwraps that union in templates to `boolean`; the Vue language plugin may not.
71
+ - Narrow in script (`const isPending = mutation.isPending as Ref<boolean>`), then bind without `.value` (`:loading="isPending"`). Do not use `.value` in templates (fails `vue-tsc`) and do not add a wrapping `computed` only to satisfy the editor.
70
72
 
71
73
  ## Mutation cache behavior
72
74
 
package/README.md CHANGED
@@ -139,13 +139,27 @@ Upgrade one or more package.json dependencies and auto-commit
139
139
  pnpm exec fgd policy-upgrade-commit
140
140
  ```
141
141
 
142
- Fix one or more vulnerability overrides interactively and auto-commit
143
- (`vuln(package): override version`, or `vuln(): override N packages` with a body listing each change):
142
+ Same upgrade, then prune unused workspace overrides in a second commit if any can be dropped:
143
+
144
+ ```bash
145
+ pnpm exec fgd policy-upgrade-commit --prune-overrides
146
+ ```
147
+
148
+ Fix one or more vulnerability overrides interactively, prune unused overrides, and auto-commit
149
+ (`vuln(package): override version`, `vuln(package): prune override`, or `vuln(): override N packages` with a body listing each change):
144
150
 
145
151
  ```bash
146
152
  pnpm exec fgd audit-override-commit
147
153
  ```
148
154
 
155
+ Report, remove, or remove and auto-commit workspace overrides that no longer change `pnpm audit`:
156
+
157
+ ```bash
158
+ pnpm exec fgd prune-overrides
159
+ pnpm exec fgd prune-overrides --fix
160
+ pnpm exec fgd prune-overrides --commit
161
+ ```
162
+
149
163
  ## tsconfig in consuming repos
150
164
 
151
165
  Use `fgd sync-project-configs` (or `fgd sync-tooling-configs`) to write the local `tsconfig.json` template from `fg-devkit`.
@@ -17,7 +17,7 @@
17
17
  "@lexical/rich-text": "0.49.0",
18
18
  "@lexical/link": "0.49.0",
19
19
  "@piwikpro/vue-piwik-pro": "1.7.1",
20
- "@sentry/vue": "10.73.0",
20
+ "@sentry/vue": "10.74.0",
21
21
  "@tanstack/vue-query": "5.102.8",
22
22
  "@types/leaflet": "1.9.22",
23
23
  "@unhead/vue": "2.0.5",
@@ -77,7 +77,7 @@
77
77
  "eslint-plugin-storybook": "10.6.0",
78
78
  "globals": "15.11.0",
79
79
  "jsdom": "26.1.0",
80
- "sass": "1.104.0",
80
+ "sass": "1.104.1",
81
81
  "storybook": "10.6.0",
82
82
  "tsx": "4.23.13",
83
83
  "vega": "6.4.0",
@@ -85,12 +85,12 @@
85
85
  "vite-plugin-vue-devtools": "8.2.1",
86
86
  "vite-plugin-vue-inspector": "7.0.0",
87
87
  "vitest": "4.1.10",
88
- "yaml": "2.9.0"
88
+ "yaml": "2.9.1"
89
89
  }
90
90
  },
91
91
  "managedAnywhere": {
92
92
  "@tailwindcss/vite": "4.3.3",
93
- "@vitejs/plugin-vue": "6.0.8",
93
+ "@vitejs/plugin-vue": "6.0.9",
94
94
  "axios": "1.20.0",
95
95
  "dotenv": "16.4.5",
96
96
  "qrcode.vue": "3.11.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fg-devkit",
3
- "version": "1.16.2",
3
+ "version": "1.17.1",
4
4
  "description": "Shared configs, scripts, and DX tooling for Vue / TypeScript frontend repos",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -53,7 +53,9 @@
53
53
  "./scripts/fleet-git-discard-scoped.mjs": "./scripts/fleet-git-discard-scoped.mjs",
54
54
  "./scripts/policy-upgrade.mjs": "./scripts/policy-upgrade.mjs",
55
55
  "./scripts/policy-upgrade-commit.mjs": "./scripts/policy-upgrade-commit.mjs",
56
+ "./scripts/override-changes.mjs": "./scripts/override-changes.mjs",
56
57
  "./scripts/audit-override-commit.mjs": "./scripts/audit-override-commit.mjs",
58
+ "./scripts/prune-overrides.mjs": "./scripts/prune-overrides.mjs",
57
59
  "./scripts/check-dependency-policy.mjs": "./scripts/check-dependency-policy.mjs",
58
60
  "./scripts/sync-dependency-policy.mjs": "./scripts/sync-dependency-policy.mjs",
59
61
  "./scripts/sync-dependency-policy-commit.mjs": "./scripts/sync-dependency-policy-commit.mjs",
@@ -103,12 +105,12 @@
103
105
  },
104
106
  "dependencies": {
105
107
  "@inquirer/checkbox": "4.3.2",
106
- "@inquirer/confirm": "6.3.1",
107
- "@inquirer/input": "5.1.5",
108
- "@inquirer/select": "5.2.4",
108
+ "@inquirer/confirm": "6.3.2",
109
+ "@inquirer/input": "5.1.6",
110
+ "@inquirer/select": "5.2.5",
109
111
  "@intlify/eslint-plugin-vue-i18n": "4.5.1",
110
- "@typescript-eslint/eslint-plugin": "8.69.0",
111
- "@typescript-eslint/parser": "8.69.0",
112
+ "@typescript-eslint/eslint-plugin": "8.70.0",
113
+ "@typescript-eslint/parser": "8.70.0",
112
114
  "@vitest/eslint-plugin": "1.6.27",
113
115
  "@vue/eslint-config-prettier": "10.2.0",
114
116
  "@vue/eslint-config-typescript": "14.9.0",
@@ -118,7 +120,7 @@
118
120
  "eslint-plugin-vue": "10.11.0",
119
121
  "fallow": "2.101.0",
120
122
  "git-cliff": "2.13.1",
121
- "lefthook": "2.1.12",
123
+ "lefthook": "2.1.14",
122
124
  "npm-check-updates": "22.2.7",
123
125
  "prettier": "3.9.6",
124
126
  "tsx": "4.23.13",
@@ -32,8 +32,8 @@
32
32
  "@intlify/unplugin-vue-i18n": "11.2.5",
33
33
  "@tanstack/vue-query-devtools": "6.1.48",
34
34
  "@types/node": "22.16.5",
35
- "@vitejs/plugin-vue": "6.0.8",
36
- "fg-devkit": "1.16.2",
35
+ "fg-devkit": "1.17.1",
36
+ "@vitejs/plugin-vue": "6.0.9",
37
37
  "tailwindcss": "4.3.3",
38
38
  "tsx": "4.23.13",
39
39
  "vite": "7.3.5",
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Fix one or more vulnerabilities via `pnpm audit --fix override -i`, install, then auto-commit.
3
+ * Fix one or more vulnerabilities via `pnpm audit --fix override -i`, prune unused
4
+ * overrides, install, then auto-commit.
4
5
  *
5
6
  * Usage:
6
7
  * pnpm exec fg-devkit audit-override-commit [pnpm-audit-args...]
@@ -9,79 +10,17 @@
9
10
  import fs from 'node:fs'
10
11
  import path from 'node:path'
11
12
  import { spawnSync } from 'node:child_process'
13
+ import { fileURLToPath } from 'node:url'
14
+ import {
15
+ buildOverrideCommitMessage,
16
+ collectOverrideChanges,
17
+ collectOverrides,
18
+ formatOverrideValue,
19
+ } from './override-changes.mjs'
12
20
 
13
21
  const cwd = process.cwd()
22
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url))
14
23
  const packageJsonPath = path.join(cwd, 'package.json')
15
- const workspaceYamlPath = path.join(cwd, 'pnpm-workspace.yaml')
16
-
17
- /** Strip surrounding single/double quotes from YAML scalar keys/values. */
18
- function unquoteYamlScalar(value) {
19
- if (
20
- (value.startsWith("'") && value.endsWith("'")) ||
21
- (value.startsWith('"') && value.endsWith('"'))
22
- ) {
23
- return value.slice(1, -1)
24
- }
25
- return value
26
- }
27
-
28
- function getOverridePackageName(overrideKey) {
29
- const key = unquoteYamlScalar(overrideKey.trim())
30
- const match = key.match(/^(@[^/]+\/[^@]+|[^@]+)@/)
31
- return match ? match[1] : key
32
- }
33
-
34
- /** Read flat `overrides:` entries from pnpm-workspace.yaml (no YAML dependency). */
35
- function readWorkspaceOverrides(yamlPath) {
36
- if (!fs.existsSync(yamlPath)) return {}
37
- const overrides = {}
38
- let inOverrides = false
39
- for (const line of fs.readFileSync(yamlPath, 'utf8').split('\n')) {
40
- if (/^overrides:\s*$/.test(line)) {
41
- inOverrides = true
42
- continue
43
- }
44
- if (!inOverrides) continue
45
- if (/^\S/.test(line)) break
46
- const match = line.match(/^ ([^:]+):\s*(.+?)\s*$/)
47
- if (match) {
48
- const key = unquoteYamlScalar(match[1].trim())
49
- const value = unquoteYamlScalar(match[2].trim())
50
- overrides[key] = value
51
- }
52
- }
53
- return overrides
54
- }
55
-
56
- function readJson(jsonPath) {
57
- return JSON.parse(fs.readFileSync(jsonPath, 'utf8'))
58
- }
59
-
60
- function collectOverrides() {
61
- const overrides = {}
62
-
63
- if (fs.existsSync(packageJsonPath)) {
64
- const pkg = readJson(packageJsonPath)
65
- Object.assign(overrides, pkg.overrides ?? {})
66
- Object.assign(overrides, pkg.pnpm?.overrides ?? {})
67
- }
68
-
69
- Object.assign(overrides, readWorkspaceOverrides(workspaceYamlPath))
70
- return overrides
71
- }
72
-
73
- function collectOverrideChanges(before, after) {
74
- const changed = []
75
- const keys = new Set([...Object.keys(before), ...Object.keys(after)])
76
- for (const key of keys) {
77
- const from = before[key]
78
- const to = after[key]
79
- if (to !== undefined && from !== to) {
80
- changed.push({ key, name: getOverridePackageName(key), from, to })
81
- }
82
- }
83
- return changed
84
- }
85
24
 
86
25
  function runPnpm(args) {
87
26
  const result = spawnSync('pnpm', args, {
@@ -109,6 +48,20 @@ function runGit(args) {
109
48
  return result.status ?? 1
110
49
  }
111
50
 
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
61
+ }
62
+ return result.status ?? 1
63
+ }
64
+
112
65
  function stageExistingFiles(files) {
113
66
  const existingFiles = files.filter((filePath) => fs.existsSync(path.join(cwd, filePath)))
114
67
  if (existingFiles.length === 0) return 0
@@ -123,7 +76,7 @@ function main() {
123
76
 
124
77
  let beforeOverrides
125
78
  try {
126
- beforeOverrides = collectOverrides()
79
+ beforeOverrides = collectOverrides(cwd)
127
80
  } catch (err) {
128
81
  console.error(`[audit-override-commit] Failed to read overrides before audit fix: ${err.message}`)
129
82
  process.exit(1)
@@ -137,9 +90,19 @@ function main() {
137
90
  process.exit(auditExitCode)
138
91
  }
139
92
 
93
+ const installExitCode = runPnpm(['install'])
94
+ if (installExitCode !== 0) {
95
+ process.exit(installExitCode)
96
+ }
97
+
98
+ const pruneExitCode = runPruneOverridesFix()
99
+ if (pruneExitCode !== 0) {
100
+ process.exit(pruneExitCode)
101
+ }
102
+
140
103
  let afterOverrides
141
104
  try {
142
- afterOverrides = collectOverrides()
105
+ afterOverrides = collectOverrides(cwd)
143
106
  } catch (err) {
144
107
  console.error(`[audit-override-commit] Failed to read overrides after audit fix: ${err.message}`)
145
108
  process.exit(1)
@@ -154,12 +117,7 @@ function main() {
154
117
  console.log('[audit-override-commit] Override changes detected:')
155
118
  for (const item of changed) {
156
119
  const fromLabel = item.from ?? '(missing)'
157
- console.log(` - ${item.name}: ${fromLabel} -> ${item.to}`)
158
- }
159
-
160
- const installExitCode = runPnpm(['install'])
161
- if (installExitCode !== 0) {
162
- process.exit(installExitCode)
120
+ console.log(` - ${item.name}: ${fromLabel} -> ${formatOverrideValue(item.to)}`)
163
121
  }
164
122
 
165
123
  const stageExitCode = stageExistingFiles(['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'])
@@ -167,7 +125,7 @@ function main() {
167
125
  process.exit(stageExitCode)
168
126
  }
169
127
 
170
- const { subject, body } = buildCommitMessage(changed)
128
+ const { subject, body } = buildOverrideCommitMessage(changed)
171
129
  console.log(`[audit-override-commit] Auto-committing: ${subject}`)
172
130
  if (body) {
173
131
  console.log(body)
@@ -176,16 +134,4 @@ function main() {
176
134
  process.exit(runGit(commitArgs))
177
135
  }
178
136
 
179
- function buildCommitMessage(changed) {
180
- if (changed.length === 1) {
181
- const only = changed[0]
182
- return { subject: `vuln(${only.name}): override ${only.to}`, body: '' }
183
- }
184
-
185
- const body = changed
186
- .map((item) => `- ${item.name}: ${item.from ?? '(missing)'} -> ${item.to}`)
187
- .join('\n')
188
- return { subject: `vuln(): override ${changed.length} packages`, body }
189
- }
190
-
191
137
  main()
@@ -15,8 +15,9 @@
15
15
  * pnpm exec fg-devkit sync-node-toolchain [policy-file] [--dry-run]
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
- * pnpm exec fg-devkit policy-upgrade-commit [ncu-args...]
18
+ * pnpm exec fg-devkit policy-upgrade-commit [--prune-overrides] [ncu-args...]
19
19
  * pnpm exec fg-devkit audit-override-commit [pnpm-audit-args...]
20
+ * pnpm exec fg-devkit prune-overrides [--fix] [--commit]
20
21
  * pnpm exec fg-devkit sync-project-configs (VS Code + tooling sync; overwrites config files)
21
22
  * pnpm exec fg-devkit sync-tooling-configs (tooling sync only; overwrites config files)
22
23
  * pnpm exec fg-devkit sync-vscode-settings
@@ -474,6 +475,7 @@ const DIRECT_COMMANDS = {
474
475
  'policy-upgrade': 'policy-upgrade.mjs',
475
476
  'policy-upgrade-commit': 'policy-upgrade-commit.mjs',
476
477
  'audit-override-commit': 'audit-override-commit.mjs',
478
+ 'prune-overrides': 'prune-overrides.mjs',
477
479
  'sync-project-configs': 'sync-project-configs.mjs',
478
480
  'sync-tooling-configs': 'sync-tooling-configs.mjs',
479
481
  'sync-vscode-settings': 'sync-vscode-settings.mjs',
@@ -532,10 +534,13 @@ Commands:
532
534
  Options: --dry-run, --fg-devkit-only, --pick, --all, --only=a,b
533
535
  policy-upgrade [policy-file] [ncu-args...]
534
536
  Upgrade policy packages via ncu (interactive) and auto-commit
535
- policy-upgrade-commit [ncu-args...]
536
- Upgrade one or more consumer package.json packages and auto-commit
537
+ policy-upgrade-commit [--prune-overrides] [ncu-args...]
538
+ Upgrade consumer package.json packages and auto-commit
539
+ --prune-overrides also drops unused workspace overrides in a second commit
537
540
  audit-override-commit [pnpm-audit-args...]
538
- Fix one or more vulnerability overrides via pnpm audit and auto-commit
541
+ Fix vulnerability overrides via pnpm audit, prune unused ones, auto-commit
542
+ prune-overrides [--fix] [--commit]
543
+ Report unused workspace overrides; --fix removes them; --commit also auto-commits
539
544
  sync-project-configs VS Code + tooling sync (overwrites config files)
540
545
  sync-tooling-configs Tooling sync only (overwrites config files)
541
546
  sync-vscode-settings Copy shared VS Code settings into .vscode/settings.json
@@ -812,7 +817,9 @@ async function runInteractiveMenu() {
812
817
  { name: 'Check', value: 'check' },
813
818
  { name: 'Sync and auto-commit', value: 'sync-commit' },
814
819
  { name: 'Upgrade package.json packages and auto-commit', value: 'upgrade-commit' },
820
+ { name: 'Upgrade package.json packages, prune overrides, and auto-commit', value: 'upgrade-commit-prune' },
815
821
  { name: 'Fix vulnerability overrides and auto-commit', value: 'audit-override-commit' },
822
+ { name: 'Prune unused vulnerability overrides and auto-commit', value: 'prune-overrides' },
816
823
  { name: 'Cancel', value: 'cancel' },
817
824
  ],
818
825
  default: 'check',
@@ -823,10 +830,19 @@ async function runInteractiveMenu() {
823
830
  if (choice === 'sync-commit') {
824
831
  return runSelectedAction(() => runNode('sync-dependency-policy-commit.mjs'))
825
832
  }
833
+ if (choice === 'upgrade-commit') {
834
+ return runSelectedAction(() => runNode('policy-upgrade-commit.mjs'))
835
+ }
836
+ if (choice === 'upgrade-commit-prune') {
837
+ return runSelectedAction(() => runNode('policy-upgrade-commit.mjs', ['--prune-overrides']))
838
+ }
826
839
  if (choice === 'audit-override-commit') {
827
840
  return runSelectedAction(() => runNode('audit-override-commit.mjs'))
828
841
  }
829
- return runSelectedAction(() => runNode('policy-upgrade-commit.mjs'))
842
+ if (choice === 'prune-overrides') {
843
+ return runSelectedAction(() => runNode('prune-overrides.mjs', ['--commit']))
844
+ }
845
+ return 1
830
846
  }
831
847
 
832
848
  if (group === 'sync-configs') {
@@ -0,0 +1,83 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ function unquoteYamlScalar(value) {
5
+ if (
6
+ (value.startsWith("'") && value.endsWith("'")) ||
7
+ (value.startsWith('"') && value.endsWith('"'))
8
+ ) {
9
+ return value.slice(1, -1)
10
+ }
11
+ return value
12
+ }
13
+
14
+ export function getOverridePackageName(overrideKey) {
15
+ const key = unquoteYamlScalar(overrideKey.trim())
16
+ const match = key.match(/^(@[^/]+\/[^@]+|[^@]+)@/)
17
+ return match ? match[1] : key
18
+ }
19
+
20
+ function readWorkspaceOverrides(yamlPath) {
21
+ if (!fs.existsSync(yamlPath)) return {}
22
+ const overrides = {}
23
+ let inOverrides = false
24
+ for (const line of fs.readFileSync(yamlPath, 'utf8').split('\n')) {
25
+ if (/^overrides:\s*$/.test(line)) {
26
+ inOverrides = true
27
+ continue
28
+ }
29
+ if (!inOverrides) continue
30
+ if (/^\S/.test(line)) break
31
+ const match = line.match(/^ ([^:]+):\s*(.+?)\s*$/)
32
+ if (match) {
33
+ const key = unquoteYamlScalar(match[1].trim())
34
+ const value = unquoteYamlScalar(match[2].trim())
35
+ overrides[key] = value
36
+ }
37
+ }
38
+ return overrides
39
+ }
40
+
41
+ export function collectOverrides(cwd = process.cwd()) {
42
+ const overrides = {}
43
+ const packageJsonPath = path.join(cwd, 'package.json')
44
+ if (fs.existsSync(packageJsonPath)) {
45
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
46
+ Object.assign(overrides, pkg.overrides ?? {})
47
+ Object.assign(overrides, pkg.pnpm?.overrides ?? {})
48
+ }
49
+ Object.assign(overrides, readWorkspaceOverrides(path.join(cwd, 'pnpm-workspace.yaml')))
50
+ return overrides
51
+ }
52
+
53
+ export function collectOverrideChanges(before, after) {
54
+ const changed = []
55
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)])
56
+ for (const key of keys) {
57
+ const from = before[key]
58
+ const to = after[key]
59
+ if (from !== to) {
60
+ changed.push({ key, name: getOverridePackageName(key), from, to })
61
+ }
62
+ }
63
+ return changed
64
+ }
65
+
66
+ export function formatOverrideValue(value) {
67
+ return value ?? '(removed)'
68
+ }
69
+
70
+ export function buildOverrideCommitMessage(changed) {
71
+ if (changed.length === 1) {
72
+ const only = changed[0]
73
+ if (only.to === undefined) {
74
+ return { subject: `vuln(${only.name}): prune override`, body: '' }
75
+ }
76
+ return { subject: `vuln(${only.name}): override ${only.to}`, body: '' }
77
+ }
78
+
79
+ const body = changed
80
+ .map((item) => `- ${item.name}: ${item.from ?? '(missing)'} -> ${formatOverrideValue(item.to)}`)
81
+ .join('\n')
82
+ return { subject: `vuln(): override ${changed.length} packages`, body }
83
+ }
@@ -4,15 +4,33 @@
4
4
  *
5
5
  * Usage:
6
6
  * pnpm exec fg-devkit policy-upgrade-commit [ncu-args...]
7
+ * pnpm exec fg-devkit policy-upgrade-commit --prune-overrides [ncu-args...]
7
8
  */
8
9
 
9
10
  import fs from 'node:fs'
10
11
  import path from 'node:path'
11
12
  import { spawnSync } from 'node:child_process'
12
13
  import { createRequire } from 'node:module'
14
+ import { fileURLToPath } from 'node:url'
15
+ import {
16
+ buildOverrideCommitMessage,
17
+ collectOverrideChanges,
18
+ collectOverrides,
19
+ formatOverrideValue,
20
+ } from './override-changes.mjs'
13
21
 
14
22
  const require = createRequire(import.meta.url)
15
- const packageJsonPath = path.join(process.cwd(), 'package.json')
23
+ const cwd = process.cwd()
24
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url))
25
+ const packageJsonPath = path.join(cwd, 'package.json')
26
+ const PRUNE_FLAG = '--prune-overrides'
27
+
28
+ function splitArgs(argv) {
29
+ return {
30
+ pruneOverrides: argv.includes(PRUNE_FLAG),
31
+ ncuArgs: argv.filter((arg) => arg !== PRUNE_FLAG),
32
+ }
33
+ }
16
34
 
17
35
  function runNcu(args = []) {
18
36
  let ncuBin
@@ -31,7 +49,7 @@ function runNcu(args = []) {
31
49
 
32
50
  const result = spawnSync(process.execPath, [ncuBin, ...effectiveArgs], {
33
51
  stdio: 'inherit',
34
- cwd: process.cwd(),
52
+ cwd,
35
53
  env: process.env,
36
54
  })
37
55
  if (result.error) {
@@ -44,7 +62,21 @@ function runNcu(args = []) {
44
62
  function runGit(args) {
45
63
  const result = spawnSync('git', args, {
46
64
  stdio: 'inherit',
47
- cwd: process.cwd(),
65
+ cwd,
66
+ env: process.env,
67
+ })
68
+ if (result.error) {
69
+ console.error('[policy-upgrade-commit]', result.error.message)
70
+ return 1
71
+ }
72
+ return result.status ?? 1
73
+ }
74
+
75
+ function runPruneOverridesFix() {
76
+ console.log('[policy-upgrade-commit] Pruning overrides that no longer affect pnpm audit…')
77
+ const result = spawnSync(process.execPath, [path.join(scriptDir, 'prune-overrides.mjs'), '--fix'], {
78
+ stdio: 'inherit',
79
+ cwd,
48
80
  env: process.env,
49
81
  })
50
82
  if (result.error) {
@@ -55,7 +87,7 @@ function runGit(args) {
55
87
  }
56
88
 
57
89
  function stageExistingFiles(files) {
58
- const existingFiles = files.filter((filePath) => fs.existsSync(path.join(process.cwd(), filePath)))
90
+ const existingFiles = files.filter((filePath) => fs.existsSync(path.join(cwd, filePath)))
59
91
  if (existingFiles.length === 0) return 0
60
92
  return runGit(['add', '--', ...existingFiles])
61
93
  }
@@ -87,7 +119,6 @@ function collectPackageVersionChanges(beforePackage, afterPackage) {
87
119
  return changed
88
120
  }
89
121
 
90
- /** ncu can upgrade package.json `packageManager` (e.g. pnpm@x.y.z); treat as one bump. */
91
122
  function collectPackageManagerChange(beforePackage, afterPackage) {
92
123
  const before = beforePackage.packageManager
93
124
  const after = afterPackage.packageManager
@@ -102,12 +133,80 @@ function collectPackageManagerChange(beforePackage, afterPackage) {
102
133
  }
103
134
  }
104
135
 
136
+ function buildCommitMessage(changed) {
137
+ if (changed.length === 1) {
138
+ const only = changed[0]
139
+ return { subject: `dep(${only.name}): bump to ${only.to}`, body: '' }
140
+ }
141
+
142
+ const body = changed.map((item) => `- ${item.name}: ${item.from} -> ${item.to}`).join('\n')
143
+ return { subject: `dep(): bump ${changed.length} packages`, body }
144
+ }
145
+
146
+ function commitChanges(prefix, subject, body) {
147
+ const stageExitCode = stageExistingFiles(['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'])
148
+ if (stageExitCode !== 0) {
149
+ return stageExitCode
150
+ }
151
+ console.log(`[${prefix}] Auto-committing: ${subject}`)
152
+ if (body) {
153
+ console.log(body)
154
+ }
155
+ const commitArgs = body ? ['commit', '-m', subject, '-m', body] : ['commit', '-m', subject]
156
+ return runGit(commitArgs)
157
+ }
158
+
159
+ function pruneAndCommitOverrides() {
160
+ let beforeOverrides
161
+ try {
162
+ beforeOverrides = collectOverrides(cwd)
163
+ } catch (err) {
164
+ console.error(`[policy-upgrade-commit] Failed to read overrides before prune: ${err.message}`)
165
+ return 1
166
+ }
167
+
168
+ if (Object.keys(beforeOverrides).length === 0) {
169
+ console.log('[policy-upgrade-commit] No overrides to prune.')
170
+ return 0
171
+ }
172
+
173
+ const pruneExitCode = runPruneOverridesFix()
174
+ if (pruneExitCode !== 0) {
175
+ return pruneExitCode
176
+ }
177
+
178
+ let afterOverrides
179
+ try {
180
+ afterOverrides = collectOverrides(cwd)
181
+ } catch (err) {
182
+ console.error(`[policy-upgrade-commit] Failed to read overrides after prune: ${err.message}`)
183
+ return 1
184
+ }
185
+
186
+ const changed = collectOverrideChanges(beforeOverrides, afterOverrides)
187
+ if (changed.length === 0) {
188
+ console.log('[policy-upgrade-commit] No unused overrides to prune.')
189
+ return 0
190
+ }
191
+
192
+ console.log('[policy-upgrade-commit] Override changes detected:')
193
+ for (const item of changed) {
194
+ const fromLabel = item.from ?? '(missing)'
195
+ console.log(` - ${item.name}: ${fromLabel} -> ${formatOverrideValue(item.to)}`)
196
+ }
197
+
198
+ const { subject, body } = buildOverrideCommitMessage(changed)
199
+ return commitChanges('policy-upgrade-commit', subject, body)
200
+ }
201
+
105
202
  function main() {
106
203
  if (!fs.existsSync(packageJsonPath)) {
107
204
  console.error(`[policy-upgrade-commit] package.json not found at ${packageJsonPath}`)
108
205
  process.exit(1)
109
206
  }
110
207
 
208
+ const { pruneOverrides, ncuArgs } = splitArgs(process.argv.slice(2))
209
+
111
210
  let beforePackage
112
211
  try {
113
212
  beforePackage = readJson(packageJsonPath)
@@ -116,7 +215,7 @@ function main() {
116
215
  process.exit(1)
117
216
  }
118
217
 
119
- const upgradeExitCode = runNcu(process.argv.slice(2))
218
+ const upgradeExitCode = runNcu(ncuArgs)
120
219
  if (upgradeExitCode !== 0) {
121
220
  process.exit(upgradeExitCode)
122
221
  }
@@ -137,36 +236,24 @@ function main() {
137
236
 
138
237
  if (changed.length === 0) {
139
238
  console.log('[policy-upgrade-commit] No package version changes detected in package.json.')
140
- process.exit(0)
141
- }
142
-
143
- console.log('[policy-upgrade-commit] Package version changes detected in package.json:')
144
- for (const item of changed) {
145
- console.log(` - ${item.name}: ${item.from} -> ${item.to}`)
146
- }
239
+ } else {
240
+ console.log('[policy-upgrade-commit] Package version changes detected in package.json:')
241
+ for (const item of changed) {
242
+ console.log(` - ${item.name}: ${item.from} -> ${item.to}`)
243
+ }
147
244
 
148
- const stageExitCode = stageExistingFiles(['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'])
149
- if (stageExitCode !== 0) {
150
- process.exit(stageExitCode)
245
+ const { subject, body } = buildCommitMessage(changed)
246
+ const commitExitCode = commitChanges('policy-upgrade-commit', subject, body)
247
+ if (commitExitCode !== 0) {
248
+ process.exit(commitExitCode)
249
+ }
151
250
  }
152
251
 
153
- const { subject, body } = buildCommitMessage(changed)
154
- console.log(`[policy-upgrade-commit] Auto-committing: ${subject}`)
155
- if (body) {
156
- console.log(body)
157
- }
158
- const commitArgs = body ? ['commit', '-m', subject, '-m', body] : ['commit', '-m', subject]
159
- process.exit(runGit(commitArgs))
160
- }
161
-
162
- function buildCommitMessage(changed) {
163
- if (changed.length === 1) {
164
- const only = changed[0]
165
- return { subject: `dep(${only.name}): bump to ${only.to}`, body: '' }
252
+ if (!pruneOverrides) {
253
+ process.exit(0)
166
254
  }
167
255
 
168
- const body = changed.map((item) => `- ${item.name}: ${item.from} -> ${item.to}`).join('\n')
169
- return { subject: `dep(): bump ${changed.length} packages`, body }
256
+ process.exit(pruneAndCommitOverrides())
170
257
  }
171
258
 
172
259
  main()
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Find pnpm-workspace.yaml overrides that no longer change the audit report.
4
+ *
5
+ * Each remaining override is removed in isolation (in a temp copy), the lockfile
6
+ * is re-resolved, and `pnpm audit --json` is compared to the current remaining
7
+ * set. That way overlapping rules (e.g. three nodemailer selectors) are not all
8
+ * dropped just because each is redundant while the others exist.
9
+ *
10
+ * Usage:
11
+ * pnpm exec fg-devkit prune-overrides # report only
12
+ * pnpm exec fg-devkit prune-overrides --fix # drop the prunable entries
13
+ * pnpm exec fg-devkit prune-overrides --commit # drop them and auto-commit
14
+ */
15
+
16
+ import { mkdtempSync, rmSync, cpSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
17
+ import { tmpdir } from 'node:os'
18
+ import { join } from 'node:path'
19
+ import { spawnSync } from 'node:child_process'
20
+ import { parseArgs } from 'node:util'
21
+ import {
22
+ buildOverrideCommitMessage,
23
+ collectOverrideChanges,
24
+ collectOverrides,
25
+ formatOverrideValue,
26
+ } from './override-changes.mjs'
27
+
28
+ const ROOT = process.cwd()
29
+ const WORKSPACE_FILE = 'pnpm-workspace.yaml'
30
+ const COPIED_FILES = ['package.json', 'pnpm-lock.yaml', WORKSPACE_FILE, '.npmrc']
31
+
32
+ const { values: args } = parseArgs({
33
+ options: {
34
+ fix: { type: 'boolean', default: false },
35
+ commit: { type: 'boolean', default: false },
36
+ help: { type: 'boolean', short: 'h', default: false },
37
+ },
38
+ strict: true,
39
+ })
40
+
41
+ if (args.help) {
42
+ console.log(`Usage: pnpm exec fg-devkit prune-overrides [--fix] [--commit]
43
+
44
+ Reports workspace overrides that can be removed without introducing new
45
+ pnpm audit findings. Pass --fix to delete those entries in place.
46
+ Pass --commit to apply the deletions and auto-commit.`)
47
+ process.exit(0)
48
+ }
49
+
50
+ const shouldFix = args.fix || args.commit
51
+
52
+ function runGit(gitArgs) {
53
+ const result = spawnSync('git', gitArgs, {
54
+ stdio: 'inherit',
55
+ cwd: ROOT,
56
+ env: process.env,
57
+ })
58
+ if (result.error) {
59
+ console.error('[prune-overrides]', result.error.message)
60
+ return 1
61
+ }
62
+ return result.status ?? 1
63
+ }
64
+
65
+ function commitOverrideChanges(beforeOverrides, afterOverrides) {
66
+ const changed = collectOverrideChanges(beforeOverrides, afterOverrides)
67
+ if (changed.length === 0) {
68
+ console.log('[prune-overrides] No override changes to commit.')
69
+ return 0
70
+ }
71
+
72
+ console.log('[prune-overrides] Override changes detected:')
73
+ for (const item of changed) {
74
+ const fromLabel = item.from ?? '(missing)'
75
+ console.log(` - ${item.name}: ${fromLabel} -> ${formatOverrideValue(item.to)}`)
76
+ }
77
+
78
+ const files = ['package.json', 'pnpm-lock.yaml', WORKSPACE_FILE].filter((file) =>
79
+ existsSync(join(ROOT, file)),
80
+ )
81
+ if (files.length === 0) {
82
+ console.error('[prune-overrides] No files to stage.')
83
+ return 1
84
+ }
85
+
86
+ const stageExitCode = runGit(['add', '--', ...files])
87
+ if (stageExitCode !== 0) {
88
+ return stageExitCode
89
+ }
90
+
91
+ const { subject, body } = buildOverrideCommitMessage(changed)
92
+ console.log(`[prune-overrides] Auto-committing: ${subject}`)
93
+ if (body) {
94
+ console.log(body)
95
+ }
96
+ const commitArgs = body ? ['commit', '-m', subject, '-m', body] : ['commit', '-m', subject]
97
+ return runGit(commitArgs)
98
+ }
99
+
100
+ function runPnpm(cwd, pnpmArgs, { allowFailure = false } = {}) {
101
+ const result = spawnSync('pnpm', pnpmArgs, {
102
+ cwd,
103
+ encoding: 'utf8',
104
+ env: { ...process.env, CI: 'true' },
105
+ maxBuffer: 20 * 1024 * 1024,
106
+ })
107
+ if (result.error) {
108
+ throw result.error
109
+ }
110
+ if (!allowFailure && result.status !== 0) {
111
+ const detail = (result.stderr || result.stdout || '').trim()
112
+ throw new Error(`pnpm ${pnpmArgs.join(' ')} failed (exit ${result.status})\n${detail}`)
113
+ }
114
+ return result
115
+ }
116
+
117
+ function splitYamlSections(text) {
118
+ const lines = text.split('\n')
119
+ const preamble = []
120
+ const sections = []
121
+ let current = null
122
+ for (const line of lines) {
123
+ const isTopLevel =
124
+ line !== '' &&
125
+ !line.startsWith(' ') &&
126
+ !line.startsWith('\t') &&
127
+ !line.startsWith('#') &&
128
+ /^[A-Za-z][\w.-]*:/.test(line)
129
+ if (isTopLevel) {
130
+ current = { header: line, body: [] }
131
+ sections.push(current)
132
+ } else if (current) {
133
+ current.body.push(line)
134
+ } else {
135
+ preamble.push(line)
136
+ }
137
+ }
138
+ return { preamble, sections }
139
+ }
140
+
141
+ function joinYamlSections({ preamble, sections }) {
142
+ const chunks = []
143
+ if (preamble.some((line) => line !== '')) {
144
+ chunks.push(preamble.join('\n'))
145
+ }
146
+ for (const section of sections) {
147
+ const body = section.body
148
+ const trimmed = [...body]
149
+ while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') {
150
+ trimmed.pop()
151
+ }
152
+ chunks.push([section.header, ...trimmed].join('\n'))
153
+ }
154
+ return `${chunks.join('\n')}\n`
155
+ }
156
+
157
+ function parseOverrideLine(line) {
158
+ const match = line.match(/^(\s+)(?:"([^"]+)"|'([^']+)'|([^:]+)):\s*(.*?)\s*$/)
159
+ if (!match) {
160
+ return null
161
+ }
162
+ return { key: match[2] ?? match[3] ?? match[4], value: match[5] }
163
+ }
164
+
165
+ function readOverrides(workspaceText) {
166
+ const doc = splitYamlSections(workspaceText)
167
+ const section = doc.sections.find((item) => item.header.startsWith('overrides:'))
168
+ if (!section) {
169
+ return { doc, entries: [] }
170
+ }
171
+ const entries = []
172
+ for (const line of section.body) {
173
+ if (!line.trim() || line.trim().startsWith('#')) {
174
+ continue
175
+ }
176
+ const parsed = parseOverrideLine(line)
177
+ if (!parsed) {
178
+ throw new Error(`cannot parse override line: ${line}`)
179
+ }
180
+ entries.push({ ...parsed, line })
181
+ }
182
+ return { doc, entries }
183
+ }
184
+
185
+ function writeOverrides(workspaceText, keepKeys) {
186
+ const keep = new Set(keepKeys)
187
+ const { doc, entries } = readOverrides(workspaceText)
188
+ const sectionIndex = doc.sections.findIndex((item) => item.header.startsWith('overrides:'))
189
+ if (sectionIndex === -1) {
190
+ return workspaceText
191
+ }
192
+ const kept = entries.filter((entry) => keep.has(entry.key))
193
+ if (kept.length === 0) {
194
+ doc.sections.splice(sectionIndex, 1)
195
+ } else {
196
+ doc.sections[sectionIndex].body = kept.map((entry) => entry.line)
197
+ }
198
+ return joinYamlSections(doc)
199
+ }
200
+
201
+ function advisoryIds(report) {
202
+ const ids = new Set()
203
+ if (report?.advisories && typeof report.advisories === 'object') {
204
+ for (const [key, advisory] of Object.entries(report.advisories)) {
205
+ ids.add(advisory?.github_advisory_id || advisory?.id || key)
206
+ }
207
+ }
208
+ return ids
209
+ }
210
+
211
+ function parseAuditJson(stdout) {
212
+ const text = stdout.trim()
213
+ try {
214
+ return JSON.parse(text)
215
+ } catch {
216
+ const start = text.indexOf('{')
217
+ const end = text.lastIndexOf('}')
218
+ if (start >= 0 && end > start) {
219
+ return JSON.parse(text.slice(start, end + 1))
220
+ }
221
+ throw new Error(`pnpm audit --json did not return JSON\n${text.slice(0, 500)}`)
222
+ }
223
+ }
224
+
225
+ function auditProject(cwd) {
226
+ const result = runPnpm(cwd, ['audit', '--json'], { allowFailure: true })
227
+ if (result.status !== 0 && result.status !== 1) {
228
+ throw new Error(
229
+ `pnpm audit failed (exit ${result.status})\n${(result.stderr || result.stdout || '').trim()}`,
230
+ )
231
+ }
232
+ return advisoryIds(parseAuditJson(result.stdout))
233
+ }
234
+
235
+ function copyProject(dest) {
236
+ for (const file of COPIED_FILES) {
237
+ const from = join(ROOT, file)
238
+ if (existsSync(from)) {
239
+ cpSync(from, join(dest, file))
240
+ }
241
+ }
242
+ }
243
+
244
+ function resolveAndAudit(tempDir, workspaceText) {
245
+ writeFileSync(join(tempDir, WORKSPACE_FILE), workspaceText)
246
+ cpSync(join(ROOT, 'pnpm-lock.yaml'), join(tempDir, 'pnpm-lock.yaml'))
247
+ runPnpm(tempDir, ['install', '--lockfile-only', '--ignore-scripts', '--no-frozen-lockfile'])
248
+ return auditProject(tempDir)
249
+ }
250
+
251
+ function formatIds(ids) {
252
+ return [...ids].sort().join(', ')
253
+ }
254
+
255
+ function main() {
256
+ const workspacePath = join(ROOT, WORKSPACE_FILE)
257
+ if (!existsSync(workspacePath)) {
258
+ console.log('No pnpm-workspace.yaml.')
259
+ return 0
260
+ }
261
+
262
+ const original = readFileSync(workspacePath, 'utf8')
263
+ const { entries } = readOverrides(original)
264
+ if (entries.length === 0) {
265
+ console.log('No overrides in pnpm-workspace.yaml.')
266
+ return 0
267
+ }
268
+
269
+ if (!existsSync(join(ROOT, 'pnpm-lock.yaml'))) {
270
+ throw new Error('pnpm-lock.yaml not found')
271
+ }
272
+
273
+ const tempDir = mkdtempSync(join(tmpdir(), 'prune-overrides-'))
274
+ try {
275
+ copyProject(tempDir)
276
+ console.log(`Checking ${entries.length} override(s) against pnpm audit…`)
277
+ const remaining = new Map(entries.map((entry) => [entry.key, entry]))
278
+ const prunable = []
279
+ let baseline = resolveAndAudit(tempDir, writeOverrides(original, remaining.keys()))
280
+
281
+ let index = 0
282
+ for (const entry of entries) {
283
+ index += 1
284
+ const trialKeys = [...remaining.keys()].filter((key) => key !== entry.key)
285
+ process.stdout.write(`[${index}/${entries.length}] ${entry.key} … `)
286
+ try {
287
+ const trial = resolveAndAudit(tempDir, writeOverrides(original, trialKeys))
288
+ const introduced = [...trial].filter((id) => !baseline.has(id))
289
+ if (introduced.length > 0) {
290
+ console.log(`KEEP (new: ${formatIds(introduced)})`)
291
+ } else {
292
+ remaining.delete(entry.key)
293
+ prunable.push(entry.key)
294
+ baseline = trial
295
+ console.log('PRUNE')
296
+ }
297
+ } catch (error) {
298
+ const message = error instanceof Error ? error.message.split('\n')[0] : String(error)
299
+ console.log(`KEEP (${message})`)
300
+ }
301
+ }
302
+
303
+ console.log('')
304
+ if (prunable.length === 0) {
305
+ console.log('Every override still affects the audit report.')
306
+ return 0
307
+ }
308
+
309
+ console.log(`Prunable (${prunable.length}):`)
310
+ for (const key of prunable) {
311
+ console.log(` - ${key}`)
312
+ }
313
+
314
+ if (!shouldFix) {
315
+ console.log('\nRe-run with --fix to remove them, or --commit to remove and auto-commit.')
316
+ return 0
317
+ }
318
+
319
+ const beforeOverrides = collectOverrides(ROOT)
320
+ writeFileSync(workspacePath, writeOverrides(original, remaining.keys()))
321
+ console.log('\nUpdated pnpm-workspace.yaml. Refreshing lockfile…')
322
+ runPnpm(ROOT, ['install', '--no-frozen-lockfile', '--ignore-scripts'])
323
+
324
+ if (!args.commit) {
325
+ console.log('Done.')
326
+ return 0
327
+ }
328
+
329
+ return commitOverrideChanges(beforeOverrides, collectOverrides(ROOT))
330
+ } finally {
331
+ rmSync(tempDir, { recursive: true, force: true })
332
+ }
333
+ }
334
+
335
+ try {
336
+ process.exit(main())
337
+ } catch (error) {
338
+ console.error(error instanceof Error ? error.message : error)
339
+ process.exit(2)
340
+ }