fg-devkit 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +30 -7
  2. package/configs/dependency-policy.vue-workspace.json +28 -28
  3. package/package.json +12 -85
  4. package/scaffold/vue/package.json +13 -13
  5. package/scripts/audit-override-commit.mjs +167 -24
  6. package/scripts/bump-node-toolchain.mjs +8 -31
  7. package/scripts/bump-pnpm-toolchain.mjs +8 -29
  8. package/scripts/check-dependency-policy.mjs +10 -35
  9. package/scripts/fg-devkit.mjs +290 -337
  10. package/scripts/fleet-bump-fg-devkit.mjs +4 -5
  11. package/scripts/fleet-git-commit-push-scoped.mjs +6 -26
  12. package/scripts/fleet-git-discard-scoped.mjs +5 -25
  13. package/scripts/fleet-git-pull-scoped.mjs +5 -25
  14. package/scripts/fleet-vulns.mjs +4 -9
  15. package/scripts/generate-release-notes.mjs +1 -1
  16. package/scripts/{audit-fix-plan.mjs → lib/audit-fix-plan.mjs} +77 -4
  17. package/scripts/{audit-fix-plan.test.mjs → lib/audit-fix-plan.test.mjs} +50 -2
  18. package/scripts/lib/common.mjs +95 -0
  19. package/scripts/{dependency-policy-toolchain.mjs → lib/dependency-policy-toolchain.mjs} +3 -35
  20. package/scripts/{git-commit-push-fleet-common.mjs → lib/git-commit-push-fleet-common.mjs} +1 -1
  21. package/scripts/{git-discard-fleet-common.mjs → lib/git-discard-fleet-common.mjs} +1 -1
  22. package/scripts/{git-pull-fleet-common.mjs → lib/git-pull-fleet-common.mjs} +1 -1
  23. package/scripts/{override-changes.mjs → lib/override-changes.mjs} +20 -0
  24. package/scripts/{piwik-dimensions.test.mjs → lib/piwik-dimensions.test.mjs} +0 -5
  25. package/scripts/{release-notes-common.mjs → lib/release-notes-common.mjs} +5 -7
  26. package/scripts/{resolve-dependency-policy-path.mjs → lib/resolve-dependency-policy-path.mjs} +4 -3
  27. package/scripts/piwik.mjs +6 -6
  28. package/scripts/poeditor-download.mjs +1 -1
  29. package/scripts/poeditor-upload.mjs +1 -1
  30. package/scripts/policy-outdated.mjs +3 -8
  31. package/scripts/policy-upgrade-commit.mjs +5 -26
  32. package/scripts/policy-upgrade.mjs +6 -30
  33. package/scripts/policy-vulns.mjs +3 -8
  34. package/scripts/prune-overrides.mjs +5 -16
  35. package/scripts/prune-release-age-exclude.mjs +1 -1
  36. package/scripts/refresh-dependency-policy-from-repos.mjs +5 -7
  37. package/scripts/release.sh +2 -1
  38. package/scripts/scaffold-vue.mjs +1 -3
  39. package/scripts/setup-ai-rules.mjs +135 -0
  40. package/scripts/sync-consumer-lefthook.mjs +7 -24
  41. package/scripts/sync-dependency-check-suppressions-commit.mjs +7 -46
  42. package/scripts/sync-dependency-policy-commit.mjs +6 -38
  43. package/scripts/sync-dependency-policy.mjs +9 -35
  44. package/scripts/sync-node-toolchain.mjs +5 -8
  45. package/scripts/sync-pnpm-toolchain.mjs +5 -8
  46. package/scripts/sync-scaffold-vue-deps.mjs +6 -7
  47. package/scripts/sync-tooling-configs.mjs +8 -23
  48. package/scripts/sync-vite-plugin-vue-inspector.mjs +7 -39
  49. package/scripts/verify-release-notes.mjs +1 -1
  50. package/.cursor/rules/core-coding-standards.mdc +0 -291
  51. package/scaffold/vue/.gitignore +0 -13
  52. package/scripts/cz/.cz-config.cjs +0 -29
  53. package/scripts/fleet-git-commit-push.mjs +0 -75
  54. package/scripts/fleet-git-discard.mjs +0 -66
  55. package/scripts/fleet-git-pull.mjs +0 -65
  56. package/scripts/sync-core-coding-standards.mjs +0 -77
  57. package/scripts/sync-peers-from-dev.mjs +0 -31
  58. package/scripts/vuln-commit.mjs +0 -142
  59. /package/configs/{fallowrc.sync-stub.json → fallowrc.json} +0 -0
  60. /package/scripts/{dependency-policy-pins.mjs → lib/dependency-policy-pins.mjs} +0 -0
  61. /package/scripts/{fleet-project-repos.mjs → lib/fleet-project-repos.mjs} +0 -0
  62. /package/scripts/{fleet-repo-picker.mjs → lib/fleet-repo-picker.mjs} +0 -0
  63. /package/scripts/{fleet-vue-repos.mjs → lib/fleet-vue-repos.mjs} +0 -0
  64. /package/scripts/{git-fleet-common.mjs → lib/git-fleet-common.mjs} +0 -0
  65. /package/scripts/{node-toolchain-sync.mjs → lib/node-toolchain-sync.mjs} +0 -0
  66. /package/scripts/{piwik-common.mjs → lib/piwik-common.mjs} +0 -0
  67. /package/scripts/{piwik-dimensions.mjs → lib/piwik-dimensions.mjs} +0 -0
  68. /package/scripts/{pnpm-toolchain-sync.mjs → lib/pnpm-toolchain-sync.mjs} +0 -0
  69. /package/scripts/{poeditor-common.mjs → lib/poeditor-common.mjs} +0 -0
@@ -16,12 +16,11 @@
16
16
  import fs from 'node:fs'
17
17
  import path from 'node:path'
18
18
  import { spawnSync } from 'node:child_process'
19
- import { fileURLToPath } from 'node:url'
20
19
 
21
- import { collectFleetVueRepos } from './fleet-vue-repos.mjs'
22
- import { parseFleetSelectionFlags, resolveFleetRepos } from './fleet-repo-picker.mjs'
20
+ import { collectFleetVueRepos } from './lib/fleet-vue-repos.mjs'
21
+ import { parseFleetSelectionFlags, resolveFleetRepos } from './lib/fleet-repo-picker.mjs'
22
+ import { fgDevkitRoot } from './lib/common.mjs'
23
23
 
24
- const scriptDir = path.dirname(fileURLToPath(import.meta.url))
25
24
  const cliArgs = process.argv.slice(2)
26
25
  const dryRun = cliArgs.includes('--dry-run')
27
26
  const fgDevkitOnly = cliArgs.includes('--fg-devkit-only')
@@ -29,7 +28,7 @@ const { pick, all, only, filteredArgs: positionalArgs } = parseFleetSelectionFla
29
28
  const projectsRootArg = positionalArgs.find((a) => !a.startsWith('-'))
30
29
  const projectsRoot = projectsRootArg
31
30
  ? path.resolve(process.cwd(), projectsRootArg)
32
- : path.resolve(scriptDir, '../..')
31
+ : path.resolve(fgDevkitRoot, '..')
33
32
 
34
33
  const LOG_PREFIX = '[fleet-bump-fg-devkit]'
35
34
 
@@ -15,16 +15,13 @@
15
15
 
16
16
  import fs from 'node:fs'
17
17
  import path from 'node:path'
18
- import { fileURLToPath } from 'node:url'
19
18
 
20
- import { collectFleetGitRepos } from './fleet-project-repos.mjs'
21
- import { parseFleetSelectionFlags, resolveFleetRepos } from './fleet-repo-picker.mjs'
22
- import { filterCommitPushPositionalArgs, resolveCommitMessage } from './git-fleet-common.mjs'
23
- import { runFleetGitCommitPush } from './git-commit-push-fleet-common.mjs'
19
+ import { collectFleetGitRepos } from './lib/fleet-project-repos.mjs'
20
+ import { parseFleetSelectionFlags, resolveFleetRepos } from './lib/fleet-repo-picker.mjs'
21
+ import { filterCommitPushPositionalArgs, resolveCommitMessage } from './lib/git-fleet-common.mjs'
22
+ import { runFleetGitCommitPush } from './lib/git-commit-push-fleet-common.mjs'
23
+ import { assertFgDevkitMaintainerContext, fgDevkitRoot } from './lib/common.mjs'
24
24
 
25
- const scriptDir = path.dirname(fileURLToPath(import.meta.url))
26
- const fgDevkitRoot = path.resolve(scriptDir, '..')
27
- const fgDevkitPkgPath = path.join(fgDevkitRoot, 'package.json')
28
25
  const LOG_PREFIX = '[fleet-git-commit-push-scoped]'
29
26
 
30
27
  const cliArgs = process.argv.slice(2)
@@ -37,25 +34,8 @@ let projectsRoot = projectsRootArg
37
34
  ? path.resolve(process.cwd(), projectsRootArg)
38
35
  : path.resolve(fgDevkitRoot, '..')
39
36
 
40
- function assertFgDevkitMaintainerContext() {
41
- if (path.resolve(process.cwd()) !== fgDevkitRoot) {
42
- console.error(`${LOG_PREFIX} Run from the fg-devkit repository root.`)
43
- process.exit(1)
44
- }
45
- try {
46
- const pkg = JSON.parse(fs.readFileSync(fgDevkitPkgPath, 'utf8'))
47
- if (pkg?.name !== 'fg-devkit') {
48
- console.error(`${LOG_PREFIX} package.json name must be fg-devkit.`)
49
- process.exit(1)
50
- }
51
- } catch {
52
- console.error(`${LOG_PREFIX} Could not read fg-devkit package.json.`)
53
- process.exit(1)
54
- }
55
- }
56
-
57
37
  async function main() {
58
- assertFgDevkitMaintainerContext()
38
+ assertFgDevkitMaintainerContext(LOG_PREFIX)
59
39
 
60
40
  if (!fgDevkitOnly && !fs.existsSync(projectsRoot)) {
61
41
  console.error(`${LOG_PREFIX} projects root not found: ${projectsRoot}`)
@@ -15,15 +15,12 @@
15
15
 
16
16
  import fs from 'node:fs'
17
17
  import path from 'node:path'
18
- import { fileURLToPath } from 'node:url'
19
18
 
20
- import { collectFleetGitRepos } from './fleet-project-repos.mjs'
21
- import { parseFleetSelectionFlags, resolveFleetRepos } from './fleet-repo-picker.mjs'
22
- import { runFleetGitDiscard } from './git-discard-fleet-common.mjs'
19
+ import { collectFleetGitRepos } from './lib/fleet-project-repos.mjs'
20
+ import { parseFleetSelectionFlags, resolveFleetRepos } from './lib/fleet-repo-picker.mjs'
21
+ import { runFleetGitDiscard } from './lib/git-discard-fleet-common.mjs'
22
+ import { assertFgDevkitMaintainerContext, fgDevkitRoot } from './lib/common.mjs'
23
23
 
24
- const scriptDir = path.dirname(fileURLToPath(import.meta.url))
25
- const fgDevkitRoot = path.resolve(scriptDir, '..')
26
- const fgDevkitPkgPath = path.join(fgDevkitRoot, 'package.json')
27
24
  const LOG_PREFIX = '[fleet-git-discard-scoped]'
28
25
 
29
26
  const cliArgs = process.argv.slice(2)
@@ -36,25 +33,8 @@ let projectsRoot = projectsRootArg
36
33
  ? path.resolve(process.cwd(), projectsRootArg)
37
34
  : path.resolve(fgDevkitRoot, '..')
38
35
 
39
- function assertFgDevkitMaintainerContext() {
40
- if (path.resolve(process.cwd()) !== fgDevkitRoot) {
41
- console.error(`${LOG_PREFIX} Run from the fg-devkit repository root.`)
42
- process.exit(1)
43
- }
44
- try {
45
- const pkg = JSON.parse(fs.readFileSync(fgDevkitPkgPath, 'utf8'))
46
- if (pkg?.name !== 'fg-devkit') {
47
- console.error(`${LOG_PREFIX} package.json name must be fg-devkit.`)
48
- process.exit(1)
49
- }
50
- } catch {
51
- console.error(`${LOG_PREFIX} Could not read fg-devkit package.json.`)
52
- process.exit(1)
53
- }
54
- }
55
-
56
36
  async function main() {
57
- assertFgDevkitMaintainerContext()
37
+ assertFgDevkitMaintainerContext(LOG_PREFIX)
58
38
 
59
39
  if (!fgDevkitOnly && !fs.existsSync(projectsRoot)) {
60
40
  console.error(`${LOG_PREFIX} projects root not found: ${projectsRoot}`)
@@ -15,15 +15,12 @@
15
15
 
16
16
  import fs from 'node:fs'
17
17
  import path from 'node:path'
18
- import { fileURLToPath } from 'node:url'
19
18
 
20
- import { collectFleetGitRepos } from './fleet-project-repos.mjs'
21
- import { parseFleetSelectionFlags, resolveFleetRepos } from './fleet-repo-picker.mjs'
22
- import { runFleetGitPull } from './git-pull-fleet-common.mjs'
19
+ import { collectFleetGitRepos } from './lib/fleet-project-repos.mjs'
20
+ import { parseFleetSelectionFlags, resolveFleetRepos } from './lib/fleet-repo-picker.mjs'
21
+ import { runFleetGitPull } from './lib/git-pull-fleet-common.mjs'
22
+ import { assertFgDevkitMaintainerContext, fgDevkitRoot } from './lib/common.mjs'
23
23
 
24
- const scriptDir = path.dirname(fileURLToPath(import.meta.url))
25
- const fgDevkitRoot = path.resolve(scriptDir, '..')
26
- const fgDevkitPkgPath = path.join(fgDevkitRoot, 'package.json')
27
24
 
28
25
  const cliArgs = process.argv.slice(2)
29
26
  const dryRun = cliArgs.includes('--dry-run')
@@ -35,25 +32,8 @@ let projectsRoot = projectsRootArg
35
32
  ? path.resolve(process.cwd(), projectsRootArg)
36
33
  : path.resolve(fgDevkitRoot, '..')
37
34
 
38
- function assertFgDevkitMaintainerContext() {
39
- if (path.resolve(process.cwd()) !== fgDevkitRoot) {
40
- console.error('[fleet-git-pull-scoped] Run from the fg-devkit repository root.')
41
- process.exit(1)
42
- }
43
- try {
44
- const pkg = JSON.parse(fs.readFileSync(fgDevkitPkgPath, 'utf8'))
45
- if (pkg?.name !== 'fg-devkit') {
46
- console.error('[fleet-git-pull-scoped] package.json name must be fg-devkit.')
47
- process.exit(1)
48
- }
49
- } catch {
50
- console.error('[fleet-git-pull-scoped] Could not read fg-devkit package.json.')
51
- process.exit(1)
52
- }
53
- }
54
-
55
35
  async function main() {
56
- assertFgDevkitMaintainerContext()
36
+ assertFgDevkitMaintainerContext('[fleet-git-pull-scoped]')
57
37
 
58
38
  if (!fgDevkitOnly && !fs.existsSync(projectsRoot)) {
59
39
  console.error(`[fleet-git-pull-scoped] projects root not found: ${projectsRoot}`)
@@ -10,17 +10,16 @@
10
10
  import fs from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import { spawnSync } from 'node:child_process'
13
- import { fileURLToPath } from 'node:url'
14
- import { isFgDevkitRepoDir } from './fleet-project-repos.mjs'
15
- import { filterReposByOnly, parseFleetOnlyArg } from './fleet-repo-picker.mjs'
13
+ import { isFgDevkitRepoDir } from './lib/fleet-project-repos.mjs'
14
+ import { filterReposByOnly, parseFleetOnlyArg } from './lib/fleet-repo-picker.mjs'
15
+ import { fgDevkitRoot, readJson } from './lib/common.mjs'
16
16
 
17
- const scriptDir = path.dirname(fileURLToPath(import.meta.url))
18
17
  const cliArgs = process.argv.slice(2)
19
18
  const summaryOnly = cliArgs.includes('--summary-only')
20
19
  const dryRun = cliArgs.includes('--dry-run')
21
20
  const only = parseFleetOnlyArg(cliArgs)
22
21
  const projectsRootArg = cliArgs.find((a) => !a.startsWith('-'))
23
- const projectsRoot = projectsRootArg ? path.resolve(process.cwd(), projectsRootArg) : path.resolve(scriptDir, '../..')
22
+ const projectsRoot = projectsRootArg ? path.resolve(process.cwd(), projectsRootArg) : path.resolve(fgDevkitRoot, '..')
24
23
  const delayArg = cliArgs.find((a) => a.startsWith('--delay-ms='))
25
24
  const delayMs = Number(delayArg?.split('=')[1] ?? 2500)
26
25
 
@@ -38,10 +37,6 @@ function walk(dir, acc = []) {
38
37
  return acc
39
38
  }
40
39
 
41
- function readJson(p) {
42
- return JSON.parse(fs.readFileSync(p, 'utf8'))
43
- }
44
-
45
40
  function isPnpmProject(pkg) {
46
41
  return (
47
42
  typeof pkg.packageManager === 'string' && pkg.packageManager.toLowerCase().startsWith('pnpm@')
@@ -13,7 +13,7 @@ import {
13
13
  resolveCliffConfig,
14
14
  resolveGitCliffBin,
15
15
  runSyncUserChangelog,
16
- } from './release-notes-common.mjs'
16
+ } from './lib/release-notes-common.mjs'
17
17
  const cwd = process.cwd()
18
18
  const tagArg = process.argv.find((arg) => arg.startsWith('--tag='))?.slice('--tag='.length)
19
19
 
@@ -156,10 +156,6 @@ export function planOverrides(advisories, { directDependencies = new Set() } = {
156
156
 
157
157
  for (const [name, packageAdvisories] of byPackage) {
158
158
  const ghsas = [...new Set(packageAdvisories.map((advisory) => advisory.ghsa))]
159
- if (directDependencies.has(name)) {
160
- plan.direct.push({ name, ghsas, versions: collectVersions(packageAdvisories) })
161
- continue
162
- }
163
159
 
164
160
  // installed version -> highest "lowest patched version" across its advisories
165
161
  const targets = new Map()
@@ -185,6 +181,17 @@ export function planOverrides(advisories, { directDependencies = new Set() } = {
185
181
  }
186
182
  }
187
183
 
184
+ if (directDependencies.has(name)) {
185
+ // Bumped in package.json, not overridden. `to` is the lowest version that fixes every
186
+ // installed version, or undefined when some advisory cannot be fixed.
187
+ const fixable = blocked.none.size === 0 && blocked.unparseable.size === 0 && targets.size > 0
188
+ const to = fixable
189
+ ? [...targets.values()].map((item) => item.target).sort(compareVersions).at(-1)
190
+ : undefined
191
+ plan.direct.push({ name, ghsas, versions: collectVersions(packageAdvisories), to })
192
+ continue
193
+ }
194
+
188
195
  if (blocked.none.size > 0) {
189
196
  plan.noPatch.push({ name, ghsas: [...blocked.none], versions: collectVersions(packageAdvisories) })
190
197
  }
@@ -568,3 +575,69 @@ export function rewriteWorkspaceList(text, key, replacements) {
568
575
  }
569
576
  return [...output, ...lines.slice(end)].join('\n')
570
577
  }
578
+
579
+ /**
580
+ * Add `name@version` pins to the top-level minimumReleaseAgeExclude list. A version is
581
+ * merged into an existing `name@a || b` entry, skipped when a name rule already covers it,
582
+ * and otherwise appended as a new item. Creates the list when it is missing.
583
+ *
584
+ * @param {string} text
585
+ * @param {{ name: string, version: string }[]} pins
586
+ */
587
+ export function addReleaseAgeExcludes(text, pins) {
588
+ const key = 'minimumReleaseAgeExclude'
589
+ let result = String(text ?? '')
590
+ const replacements = new Map()
591
+ const appended = []
592
+ const entries = readWorkspaceList(result, key)
593
+ for (const { name, version } of pins) {
594
+ const current = [...entries.map((entry) => replacements.get(entry) ?? entry), ...appended]
595
+ if (isReleaseAgeExcluded(current, name, version)) continue
596
+ const existing = entries.find((entry) => {
597
+ const parsed = parseReleaseAgeExcludeEntry(entry)
598
+ return parsed.name === name && parsed.versions !== null
599
+ })
600
+ if (existing) {
601
+ const merged = replacements.get(existing) ?? existing
602
+ replacements.set(existing, `${merged} || ${version}`)
603
+ continue
604
+ }
605
+ const pending = appended.findIndex((entry) => parseReleaseAgeExcludeEntry(entry).name === name)
606
+ if (pending >= 0) appended[pending] = `${appended[pending]} || ${version}`
607
+ else appended.push(`${name}@${version}`)
608
+ }
609
+ if (replacements.size > 0) result = rewriteWorkspaceList(result, key, replacements)
610
+ if (appended.length === 0) return result
611
+
612
+ const lines = result.split('\n')
613
+ const start = lines.findIndex((line) => line === `${key}:` || line.startsWith(`${key}: #`))
614
+ const rendered = (indent) => appended.map((entry) => `${indent}- ${renderYamlListItem(entry)}`)
615
+ if (start === -1) {
616
+ while (lines.length > 0 && lines.at(-1) === '') lines.pop()
617
+ lines.push(`${key}:`, ...rendered(' '), '')
618
+ return lines.join('\n')
619
+ }
620
+ let last = start
621
+ let indent = ' '
622
+ for (let i = start + 1; i < lines.length; i += 1) {
623
+ if (lines[i].trim() === '' || lines[i].trim().startsWith('#')) continue
624
+ const item = lines[i].match(/^(\s+)-\s/)
625
+ if (!item) break
626
+ indent = item[1]
627
+ last = i
628
+ }
629
+ lines.splice(last + 1, 0, ...rendered(indent))
630
+ return lines.join('\n')
631
+ }
632
+
633
+ /**
634
+ * New package.json spec that pins `version` with the same prefix as `spec`
635
+ * (`1.2.3`, `^1.2.3`, `~1.2.3`, `=1.2.3`). Returns null for specs that are not a plain
636
+ * version (`workspace:*`, `catalog:`, ranges, URLs), which are left for a human.
637
+ */
638
+ export function bumpDependencySpec(spec, version) {
639
+ const match = String(spec ?? '')
640
+ .trim()
641
+ .match(/^([\^~=]?)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/)
642
+ return match ? `${match[1]}${version}` : null
643
+ }
@@ -3,7 +3,9 @@ import test from 'node:test'
3
3
 
4
4
  import {
5
5
  activeSuppressedGhsas,
6
+ addReleaseAgeExcludes,
6
7
  buildRefreshCommitMessage,
8
+ bumpDependencySpec,
7
9
  compareVersions,
8
10
  diffLockfileVersions,
9
11
  filterSuppressions,
@@ -148,11 +150,57 @@ test('planOverrides reports instead of overriding when it would be unsafe or use
148
150
  [['old', '2.4.0', '3.0.0']],
149
151
  )
150
152
  assert.deepEqual(
151
- plan.direct.map((item) => item.name),
152
- ['eslint'],
153
+ plan.direct.map((item) => [item.name, item.to]),
154
+ [['eslint', '10.0.1']],
153
155
  )
154
156
  })
155
157
 
158
+ test('planOverrides leaves the direct target empty when a finding has no patch', () => {
159
+ const plan = planOverrides(
160
+ [advisory({ name: 'vite', ghsa: 'GHSA-none', vulnerable: '<=7.0.0', patched: null, versions: ['7.0.0'] })],
161
+ { directDependencies: new Set(['vite']) },
162
+ )
163
+ assert.deepEqual(
164
+ plan.direct.map((item) => [item.name, item.to]),
165
+ [['vite', undefined]],
166
+ )
167
+ })
168
+
169
+ test('addReleaseAgeExcludes merges, skips covered names and appends', () => {
170
+ const text = ['packages: []', 'minimumReleaseAgeExclude:', ' - fg-devkit', ' - lefthook@2.1.15', ''].join('\n')
171
+ assert.equal(
172
+ addReleaseAgeExcludes(text, [
173
+ { name: 'fg-devkit', version: '1.0.0' },
174
+ { name: 'lefthook', version: '2.1.17' },
175
+ { name: '@scope/pkg', version: '1.2.3' },
176
+ { name: '@scope/pkg', version: '1.2.4' },
177
+ ]),
178
+ [
179
+ 'packages: []',
180
+ 'minimumReleaseAgeExclude:',
181
+ ' - fg-devkit',
182
+ ' - lefthook@2.1.15 || 2.1.17',
183
+ " - '@scope/pkg@1.2.3 || 1.2.4'",
184
+ '',
185
+ ].join('\n'),
186
+ )
187
+ })
188
+
189
+ test('addReleaseAgeExcludes creates the list when it is missing', () => {
190
+ assert.equal(
191
+ addReleaseAgeExcludes('packages: []\n', [{ name: 'undici', version: '6.21.2' }]),
192
+ 'packages: []\nminimumReleaseAgeExclude:\n - undici@6.21.2\n',
193
+ )
194
+ })
195
+
196
+ test('bumpDependencySpec keeps the prefix and skips non-version specs', () => {
197
+ assert.equal(bumpDependencySpec('1.7.0', '1.8.2'), '1.8.2')
198
+ assert.equal(bumpDependencySpec('^1.7.0', '1.8.2'), '^1.8.2')
199
+ assert.equal(bumpDependencySpec('~1.7.0', '1.8.2'), '~1.8.2')
200
+ assert.equal(bumpDependencySpec('workspace:*', '1.8.2'), null)
201
+ assert.equal(bumpDependencySpec('>=1.0.0 <2', '1.8.2'), null)
202
+ })
203
+
156
204
  test('isReleaseAgeExcluded understands names, patterns and version lists', () => {
157
205
  const list = ['fg-devkit', '@fallow-cli/*', 'lefthook@2.1.15 || 2.1.17']
158
206
  assert.equal(isReleaseAgeExcluded(list, 'fg-devkit', '1.0.0'), true)
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Small helpers shared by the fg-devkit scripts: JSON, git and locating fg-devkit itself.
3
+ */
4
+
5
+ import { spawnSync } from 'node:child_process'
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ /** Root of the fg-devkit package these scripts belong to. */
11
+ export const fgDevkitRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
12
+
13
+ export function readJson(filePath) {
14
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
15
+ }
16
+
17
+ /**
18
+ * Run git in `cwd`. Output goes to the terminal unless `capture` is set. A failure to start
19
+ * git is logged with `tag` and reported as status 1.
20
+ *
21
+ * @param {string[]} args
22
+ * @param {{ cwd: string, tag: string, capture?: boolean }} options
23
+ * @returns {{ status: number, stdout: string }}
24
+ */
25
+ export function runGit(args, { cwd, tag, capture = false }) {
26
+ const result = spawnSync('git', args, {
27
+ stdio: capture ? 'pipe' : 'inherit',
28
+ encoding: capture ? 'utf8' : undefined,
29
+ cwd,
30
+ env: process.env,
31
+ })
32
+ if (result.error) {
33
+ console.error(tag, result.error.message)
34
+ return { status: 1, stdout: '' }
35
+ }
36
+ return { status: result.status ?? 1, stdout: result.stdout ?? '' }
37
+ }
38
+
39
+ /**
40
+ * `git add` the files that exist, relative to `cwd`.
41
+ *
42
+ * @param {string[]} files
43
+ * @param {{ cwd: string, tag: string }} options
44
+ * @returns {number} exit status, 0 when there was nothing to stage
45
+ */
46
+ export function stageExistingFiles(files, options) {
47
+ const existingFiles = files.filter((filePath) => fs.existsSync(path.join(options.cwd, filePath)))
48
+ if (existingFiles.length === 0) return 0
49
+ return runGit(['add', '--', ...existingFiles], options).status
50
+ }
51
+
52
+ /**
53
+ * True when the file exists and is untracked or differs from HEAD.
54
+ *
55
+ * @param {string} filePath relative to `cwd`
56
+ * @param {{ cwd: string, tag: string }} options
57
+ */
58
+ export function fileDiffersFromHead(filePath, options) {
59
+ if (!fs.existsSync(path.join(options.cwd, filePath))) return false
60
+ const tracked = runGit(['ls-files', '--error-unmatch', '--', filePath], { ...options, capture: true })
61
+ if (tracked.status !== 0) return true
62
+ return runGit(['diff', '--quiet', 'HEAD', '--', filePath], { ...options, capture: true }).status !== 0
63
+ }
64
+
65
+ /**
66
+ * fg-devkit on disk for a consumer repo: this package, a direct dependency, or the copy
67
+ * bundled with fds-vue-core. Null when none of them has a package.json.
68
+ *
69
+ * @param {string} projectRoot
70
+ */
71
+ export function findFgDevkitPackageRoot(projectRoot) {
72
+ const candidates = [
73
+ fgDevkitRoot,
74
+ path.join(projectRoot, 'node_modules', 'fg-devkit'),
75
+ path.join(projectRoot, 'node_modules', 'fds-vue-core', 'node_modules', 'fg-devkit'),
76
+ ]
77
+ return candidates.find((candidate) => fs.existsSync(path.join(candidate, 'package.json'))) ?? null
78
+ }
79
+
80
+ /** Exit unless the current directory is the fg-devkit repository root. */
81
+ export function assertFgDevkitMaintainerContext(tag) {
82
+ if (path.resolve(process.cwd()) !== fgDevkitRoot) {
83
+ console.error(`${tag} Run from the fg-devkit repository root.`)
84
+ process.exit(1)
85
+ }
86
+ try {
87
+ if (readJson(path.join(fgDevkitRoot, 'package.json'))?.name !== 'fg-devkit') {
88
+ console.error(`${tag} package.json name must be fg-devkit.`)
89
+ process.exit(1)
90
+ }
91
+ } catch {
92
+ console.error(`${tag} Could not read fg-devkit package.json.`)
93
+ process.exit(1)
94
+ }
95
+ }
@@ -9,7 +9,7 @@ const PNPM_PACKAGE_MANAGER_PATTERN = /^pnpm@\d+\.\d+\.\d+(-[\w.]+)?$/i
9
9
  const NODE_NVMRC_PATTERN = /^\d+(\.\d+(\.\d+)?)?$/
10
10
 
11
11
  /** @param {unknown} value */
12
- export function isValidPnpmPackageManager(value) {
12
+ function isValidPnpmPackageManager(value) {
13
13
  return typeof value === 'string' && PNPM_PACKAGE_MANAGER_PATTERN.test(value)
14
14
  }
15
15
 
@@ -17,7 +17,7 @@ export function isValidPnpmPackageManager(value) {
17
17
  * @param {string} fgDevkitRoot
18
18
  * @returns {string | undefined}
19
19
  */
20
- export function readFgDevkitPackageManager(fgDevkitRoot) {
20
+ function readFgDevkitPackageManager(fgDevkitRoot) {
21
21
  const pkgPath = path.join(fgDevkitRoot, 'package.json')
22
22
  if (!fs.existsSync(pkgPath)) {
23
23
  return undefined
@@ -82,7 +82,7 @@ export function collectToolchainPolicyViolations(policy, labelPrefix = '[policy]
82
82
  /**
83
83
  * @param {unknown} value
84
84
  */
85
- export function isValidNodeNvmrc(value) {
85
+ function isValidNodeNvmrc(value) {
86
86
  return typeof value === 'string' && NODE_NVMRC_PATTERN.test(value.trim().replace(/^v/, ''))
87
87
  }
88
88
 
@@ -189,38 +189,6 @@ export function resolveExpectedNodePolicy(policy, fgDevkitRoot) {
189
189
  return undefined
190
190
  }
191
191
 
192
- /**
193
- * @param {Record<string, unknown>} policy
194
- * @param {string} fgDevkitRoot
195
- * @returns {{ policy: Record<string, unknown>; changed: boolean }}
196
- */
197
- export function syncPolicyNodeFromFgDevkit(policy, fgDevkitRoot) {
198
- const fromFgDevkit = readFgDevkitNodePolicy(fgDevkitRoot)
199
- if (!fromFgDevkit) {
200
- return { policy, changed: false }
201
- }
202
- const next = {
203
- ...policy,
204
- toolchain: {
205
- ...(policy.toolchain ?? {}),
206
- node: {
207
- nvmrc: fromFgDevkit.nvmrc,
208
- ...(fromFgDevkit.engines ? { engines: fromFgDevkit.engines } : {}),
209
- jenkinsImage: fromFgDevkit.jenkinsImage,
210
- dockerTag: fromFgDevkit.dockerTag,
211
- },
212
- },
213
- }
214
- const current = policy.toolchain?.node
215
- const changed =
216
- !current ||
217
- current.nvmrc !== next.toolchain.node.nvmrc ||
218
- current.engines !== next.toolchain.node.engines ||
219
- current.jenkinsImage !== next.toolchain.node.jenkinsImage ||
220
- current.dockerTag !== next.toolchain.node.dockerTag
221
- return { policy: changed ? next : policy, changed }
222
- }
223
-
224
192
  /**
225
193
  * @param {Record<string, unknown>} policy
226
194
  * @param {string} [labelPrefix]
@@ -119,7 +119,7 @@ export function runFleetGitCommitPush(options) {
119
119
  console.log(`${logPrefix} repositories=${repos.length}`)
120
120
 
121
121
  if (committed.length > 0) {
122
- console.log(`${logPrefix} committed and pushed (${committed.length}):`)
122
+ console.log(`${logPrefix} ${dryRun ? 'would commit and push' : 'committed and pushed'} (${committed.length}):`)
123
123
  for (const item of committed) {
124
124
  console.log(` - ${item.label} (${item.branch})`)
125
125
  }
@@ -72,7 +72,7 @@ export function runFleetGitDiscard(options) {
72
72
  console.log(`${logPrefix} repositories=${repos.length}`)
73
73
 
74
74
  if (discarded.length > 0) {
75
- console.log(`${logPrefix} discarded tracked changes (${discarded.length}):`)
75
+ console.log(`${logPrefix} ${dryRun ? 'would discard tracked changes' : 'discarded tracked changes'} (${discarded.length}):`)
76
76
  for (const item of discarded) {
77
77
  console.log(` - ${item.label} (${item.branch})`)
78
78
  }
@@ -69,7 +69,7 @@ export function runFleetGitPull(options) {
69
69
  console.log(`${logPrefix} repositories=${repos.length}`)
70
70
 
71
71
  if (pulled.length > 0) {
72
- console.log(`${logPrefix} pulled (${pulled.length}):`)
72
+ console.log(`${logPrefix} ${dryRun ? 'would pull' : 'pulled'} (${pulled.length}):`)
73
73
  for (const item of pulled) {
74
74
  console.log(` - ${item.label} (${item.branch})`)
75
75
  }
@@ -17,6 +17,26 @@ export function getOverridePackageName(overrideKey) {
17
17
  return match ? match[1] : key
18
18
  }
19
19
 
20
+ /**
21
+ * Policy overrides for packages present in the lockfile, so a repo only gets the overrides
22
+ * it can use. All of them when there is no lockfile.
23
+ *
24
+ * @param {Record<string, string> | undefined} policyOverrides
25
+ * @param {string} lockfilePath
26
+ */
27
+ export function getRelevantPolicyOverrides(policyOverrides, lockfilePath) {
28
+ const entries = Object.entries(policyOverrides ?? {})
29
+ if (entries.length === 0) return {}
30
+ if (!fs.existsSync(lockfilePath)) return Object.fromEntries(entries)
31
+ const lockfile = fs.readFileSync(lockfilePath, 'utf8')
32
+ return Object.fromEntries(
33
+ entries.filter(([name]) => {
34
+ const pkgName = getOverridePackageName(name)
35
+ return lockfile.includes(`${pkgName}@`) || lockfile.includes(`/${pkgName}/`)
36
+ }),
37
+ )
38
+ }
39
+
20
40
  function readWorkspaceOverrides(yamlPath) {
21
41
  if (!fs.existsSync(yamlPath)) return {}
22
42
  const overrides = {}
@@ -1,8 +1,5 @@
1
1
  import assert from 'node:assert/strict'
2
- import fs from 'node:fs'
3
- import path from 'node:path'
4
2
  import test from 'node:test'
5
- import { fileURLToPath } from 'node:url'
6
3
 
7
4
  import {
8
5
  accountFromPiwikUrl,
@@ -24,8 +21,6 @@ import {
24
21
  rewriteDimensionNumbers,
25
22
  } from './piwik-dimensions.mjs'
26
23
 
27
- const scriptDir = path.dirname(fileURLToPath(import.meta.url))
28
-
29
24
  const bookingSource = `
30
25
  export const customDimensions = {
31
26
  clientNumber: 1,
@@ -6,11 +6,9 @@
6
6
  import { spawnSync } from 'node:child_process'
7
7
  import fs from 'node:fs'
8
8
  import path from 'node:path'
9
- import { fileURLToPath } from 'node:url'
9
+ import { fgDevkitRoot } from './common.mjs'
10
10
 
11
- const scriptDir = path.dirname(fileURLToPath(import.meta.url))
12
11
 
13
- export const fgRoot = path.resolve(scriptDir, '..')
14
12
 
15
13
  /** True when the consumer repo has opted into git-cliff release notes. */
16
14
  export function hasReleaseNotesConfig(cwd = process.cwd()) {
@@ -28,7 +26,7 @@ export function resolveCliffConfig(name, cwd = process.cwd()) {
28
26
  return local
29
27
  }
30
28
 
31
- const bundled = path.join(fgRoot, 'configs', name)
29
+ const bundled = path.join(fgDevkitRoot, 'configs', name)
32
30
  if (fs.existsSync(bundled)) {
33
31
  return bundled
34
32
  }
@@ -89,20 +87,20 @@ function resolvePackageFile(packageName, relativePaths, searchRoots) {
89
87
  }
90
88
 
91
89
  function dependencySearchRoots() {
92
- return [fgRoot, process.cwd(), path.join(process.cwd(), 'node_modules', 'fg-devkit')]
90
+ return [fgDevkitRoot, process.cwd(), path.join(process.cwd(), 'node_modules', 'fg-devkit')]
93
91
  }
94
92
 
95
93
  export function resolveGitCliffBin() {
96
94
  return resolvePackageFile('git-cliff', ['lib/cli/cli.js'], dependencySearchRoots())
97
95
  }
98
96
 
99
- export function resolveTsxBin() {
97
+ function resolveTsxBin() {
100
98
  return resolvePackageFile('tsx', ['dist/cli.mjs', 'cli.mjs'], dependencySearchRoots())
101
99
  }
102
100
 
103
101
  export function runSyncUserChangelog(cwd = process.cwd()) {
104
102
  const tsxBin = resolveTsxBin()
105
- const syncScript = path.join(fgRoot, 'release-notes', 'sync-user-changelog.ts')
103
+ const syncScript = path.join(fgDevkitRoot, 'release-notes', 'sync-user-changelog.ts')
106
104
 
107
105
  if (!tsxBin) {
108
106
  console.error('[fg-devkit] Could not resolve tsx. Reinstall fg-devkit and retry.')