fg-devkit 1.12.6 → 1.12.8

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.
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Sync Node toolchain files in a single repository from policy.toolchain.node.
3
+ */
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+
8
+ import {
9
+ deriveJenkinsImageTag,
10
+ normalizeNodeVersionLine,
11
+ resolveDockerNodeTag,
12
+ resolveExpectedNodePolicy,
13
+ } from './dependency-policy-toolchain.mjs'
14
+
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
17
+ const GITHUB_NODE_VERSION_PATTERN = /node-version:\s*['"]?\d+(?:\.\d+)?['"]?/g
18
+
19
+ /**
20
+ * @param {string} dir
21
+ * @returns {string[]}
22
+ */
23
+ function findGithubWorkflows(dir) {
24
+ const workflowsDir = path.join(dir, '.github', 'workflows')
25
+ if (!fs.existsSync(workflowsDir)) {
26
+ return []
27
+ }
28
+ return fs
29
+ .readdirSync(workflowsDir, { withFileTypes: true })
30
+ .filter((entry) => entry.isFile() && /\.(ya?ml)$/i.test(entry.name))
31
+ .map((entry) => path.join(workflowsDir, entry.name))
32
+ }
33
+
34
+ /**
35
+ * @param {string} dir
36
+ * @param {string[]} dockerfileNames
37
+ * @returns {string[]}
38
+ */
39
+ function findDockerfiles(dir, dockerfileNames = ['Dockerfile', 'Dockerfile.local']) {
40
+ const found = []
41
+ for (const name of dockerfileNames) {
42
+ const filePath = path.join(dir, name)
43
+ if (fs.existsSync(filePath)) {
44
+ found.push(filePath)
45
+ }
46
+ }
47
+ return found
48
+ }
49
+
50
+ /**
51
+ * @param {string} dir
52
+ * @returns {string[]}
53
+ */
54
+ function findPackageJsonFiles(dir) {
55
+ const found = []
56
+ const skipDirNames = new Set([
57
+ 'node_modules',
58
+ '.git',
59
+ '.next',
60
+ 'dist',
61
+ 'build',
62
+ 'coverage',
63
+ 'storybook-static',
64
+ ])
65
+
66
+ function walk(currentDir) {
67
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
68
+ if (entry.isDirectory()) {
69
+ if (skipDirNames.has(entry.name) || entry.name.startsWith('.')) {
70
+ continue
71
+ }
72
+ walk(path.join(currentDir, entry.name))
73
+ continue
74
+ }
75
+
76
+ if (entry.name === 'package.json') {
77
+ found.push(path.join(currentDir, entry.name))
78
+ }
79
+ }
80
+ }
81
+
82
+ walk(dir)
83
+ return found.sort((a, b) => a.localeCompare(b))
84
+ }
85
+
86
+ /**
87
+ * @param {string} content
88
+ * @param {string} expectedNvmrc
89
+ * @param {string} [dockerTag] e.g. 22-alpine
90
+ * @returns {{ content: string, changed: boolean }}
91
+ */
92
+ export function syncDockerfileNodeImages(content, expectedNvmrc, dockerTag) {
93
+ const imageTag = resolveDockerNodeTag(expectedNvmrc, dockerTag)
94
+ let changed = false
95
+
96
+ const updated = content.replace(DOCKER_FROM_NODE_PATTERN, (match) => {
97
+ const next = `FROM node:${imageTag}`
98
+ if (next !== match) {
99
+ changed = true
100
+ }
101
+ return next
102
+ })
103
+
104
+ return { content: updated, changed }
105
+ }
106
+
107
+ /**
108
+ * @param {string} content
109
+ * @param {string} jenkinsImageTag e.g. node22
110
+ * @returns {{ content: string, changed: boolean }}
111
+ */
112
+ export function syncJenkinsfileNodeImage(content, jenkinsImageTag) {
113
+ let changed = false
114
+ const updated = content.replace(JENKINS_NODE_IMAGE_PATTERN, (match, prefix) => {
115
+ const next = `${prefix}${jenkinsImageTag}`
116
+ if (next !== match) {
117
+ changed = true
118
+ }
119
+ return next
120
+ })
121
+ return { content: updated, changed }
122
+ }
123
+
124
+ /**
125
+ * @param {string} content
126
+ * @param {string} expectedNvmrc
127
+ * @returns {{ content: string, changed: boolean }}
128
+ */
129
+ export function syncGithubWorkflowNodeVersion(content, expectedNvmrc) {
130
+ const major = expectedNvmrc.split('.')[0]
131
+ let changed = false
132
+ const updated = content.replace(GITHUB_NODE_VERSION_PATTERN, (match) => {
133
+ const next = `node-version: ${major}`
134
+ if (next !== match) {
135
+ changed = true
136
+ }
137
+ return next
138
+ })
139
+ return { content: updated, changed }
140
+ }
141
+
142
+ /**
143
+ * @param {string} filePath
144
+ * @param {string} expected
145
+ * @param {boolean} dryRun
146
+ * @returns {string | null} change description
147
+ */
148
+ function writeTextFileIfNeeded(filePath, expected, dryRun) {
149
+ const normalizedExpected = expected.trim()
150
+ const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : undefined
151
+ const normalizedCurrent = current === undefined ? undefined : normalizeNodeVersionLine(current)
152
+
153
+ if (normalizedCurrent === normalizedExpected) {
154
+ return null
155
+ }
156
+
157
+ const rel = path.basename(filePath)
158
+ const description =
159
+ current === undefined
160
+ ? `${rel}: (missing) -> ${normalizedExpected}`
161
+ : `${rel}: ${normalizedCurrent} -> ${normalizedExpected}`
162
+
163
+ if (!dryRun) {
164
+ fs.writeFileSync(filePath, `${normalizedExpected}\n`)
165
+ }
166
+ return description
167
+ }
168
+
169
+ /**
170
+ * @param {string} repoRoot
171
+ * @param {Record<string, unknown>} policy
172
+ * @param {{ dryRun?: boolean, fgDevkitRoot?: string }} [options]
173
+ * @returns {string[]} change descriptions
174
+ */
175
+ export function syncNodeToolchainInRepo(repoRoot, policy, options = {}) {
176
+ const { dryRun = false, fgDevkitRoot } = options
177
+ const nodePolicy = resolveExpectedNodePolicy(policy, fgDevkitRoot)
178
+ if (!nodePolicy) {
179
+ return []
180
+ }
181
+
182
+ const changes = []
183
+ const { nvmrc, engines, jenkinsImage, dockerTag } = nodePolicy
184
+ const rootPackageJsonPath = path.join(repoRoot, 'package.json')
185
+ const hasRootPackageJson = fs.existsSync(rootPackageJsonPath)
186
+
187
+ if (hasRootPackageJson) {
188
+ const nvmrcPath = path.join(repoRoot, '.nvmrc')
189
+ const nvmrcChange = writeTextFileIfNeeded(nvmrcPath, nvmrc, dryRun)
190
+ if (nvmrcChange) {
191
+ changes.push(nvmrcChange)
192
+ }
193
+ }
194
+
195
+ const nodeVersionPath = path.join(repoRoot, '.node-version')
196
+ if (hasRootPackageJson && fs.existsSync(nodeVersionPath)) {
197
+ const nodeVersionChange = writeTextFileIfNeeded(nodeVersionPath, nvmrc, dryRun)
198
+ if (nodeVersionChange) {
199
+ changes.push(nodeVersionChange)
200
+ }
201
+ }
202
+
203
+ if (engines) {
204
+ for (const pkgPath of findPackageJsonFiles(repoRoot)) {
205
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
206
+ const currentEngines = pkg.engines?.node
207
+ if (currentEngines === engines) {
208
+ continue
209
+ }
210
+
211
+ const relPkgPath = path.relative(repoRoot, pkgPath)
212
+ changes.push(`${relPkgPath} engines.node: ${currentEngines ?? '(missing)'} -> ${engines}`)
213
+ if (!dryRun) {
214
+ pkg.engines = { ...(pkg.engines ?? {}), node: engines }
215
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
216
+ }
217
+ }
218
+ }
219
+
220
+ for (const dockerfilePath of findDockerfiles(repoRoot)) {
221
+ const dockerfile = fs.readFileSync(dockerfilePath, 'utf8')
222
+ const { content: updatedDockerfile, changed } = syncDockerfileNodeImages(dockerfile, nvmrc, dockerTag)
223
+ if (changed) {
224
+ changes.push(`${path.basename(dockerfilePath)}: node base image -> node:${dockerTag ?? resolveDockerNodeTag(nvmrc)}`)
225
+ if (!dryRun) {
226
+ fs.writeFileSync(dockerfilePath, updatedDockerfile)
227
+ }
228
+ }
229
+ }
230
+
231
+ const jenkinsfilePath = path.join(repoRoot, 'Jenkinsfile')
232
+ if (fs.existsSync(jenkinsfilePath)) {
233
+ const jenkinsfile = fs.readFileSync(jenkinsfilePath, 'utf8')
234
+ const { content: updatedJenkinsfile, changed } = syncJenkinsfileNodeImage(
235
+ jenkinsfile,
236
+ jenkinsImage ?? deriveJenkinsImageTag(nvmrc),
237
+ )
238
+ if (changed) {
239
+ changes.push(`Jenkinsfile: jenkins-build image -> ${jenkinsImage ?? deriveJenkinsImageTag(nvmrc)}`)
240
+ if (!dryRun) {
241
+ fs.writeFileSync(jenkinsfilePath, updatedJenkinsfile)
242
+ }
243
+ }
244
+ }
245
+
246
+ for (const workflowPath of findGithubWorkflows(repoRoot)) {
247
+ const workflow = fs.readFileSync(workflowPath, 'utf8')
248
+ const { content: updatedWorkflow, changed } = syncGithubWorkflowNodeVersion(workflow, nvmrc)
249
+ if (changed) {
250
+ changes.push(
251
+ `${path.relative(repoRoot, workflowPath)}: node-version -> ${nvmrc.split('.')[0]}`,
252
+ )
253
+ if (!dryRun) {
254
+ fs.writeFileSync(workflowPath, updatedWorkflow)
255
+ }
256
+ }
257
+ }
258
+
259
+ return changes
260
+ }
261
+
262
+ /**
263
+ * @param {string} repoRoot
264
+ * @param {Record<string, unknown>} policy
265
+ * @param {string} [fgDevkitRoot]
266
+ * @returns {string[]}
267
+ */
268
+ export function collectNodeToolchainViolations(repoRoot, policy, fgDevkitRoot) {
269
+ const nodePolicy = resolveExpectedNodePolicy(policy, fgDevkitRoot)
270
+ if (!nodePolicy) {
271
+ return []
272
+ }
273
+
274
+ const violations = []
275
+ const { nvmrc, engines, jenkinsImage, dockerTag } = nodePolicy
276
+ const hasRootPackageJson = fs.existsSync(path.join(repoRoot, 'package.json'))
277
+
278
+ if (hasRootPackageJson) {
279
+ const nvmrcPath = path.join(repoRoot, '.nvmrc')
280
+ if (!fs.existsSync(nvmrcPath)) {
281
+ violations.push(`.nvmrc: expected ${nvmrc}, got (missing)`)
282
+ } else {
283
+ const actual = normalizeNodeVersionLine(fs.readFileSync(nvmrcPath, 'utf8'))
284
+ if (actual !== nvmrc) {
285
+ violations.push(`.nvmrc: expected ${nvmrc}, got ${actual}`)
286
+ }
287
+ }
288
+
289
+ const nodeVersionPath = path.join(repoRoot, '.node-version')
290
+ if (fs.existsSync(nodeVersionPath)) {
291
+ const actual = normalizeNodeVersionLine(fs.readFileSync(nodeVersionPath, 'utf8'))
292
+ if (actual !== nvmrc) {
293
+ violations.push(`.node-version: expected ${nvmrc}, got ${actual}`)
294
+ }
295
+ }
296
+ }
297
+
298
+ if (engines) {
299
+ for (const pkgPath of findPackageJsonFiles(repoRoot)) {
300
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
301
+ const actual = pkg.engines?.node
302
+ if (actual !== engines) {
303
+ violations.push(
304
+ `${path.relative(repoRoot, pkgPath)} engines.node: expected ${engines}, got ${actual ?? '(missing)'}`,
305
+ )
306
+ }
307
+ }
308
+ }
309
+
310
+ for (const dockerfilePath of findDockerfiles(repoRoot)) {
311
+ const dockerfile = fs.readFileSync(dockerfilePath, 'utf8')
312
+ const expectedFrom = `FROM node:${dockerTag ?? resolveDockerNodeTag(nvmrc)}`
313
+ for (const match of dockerfile.matchAll(/FROM node:(v)?(\d+(?:\.\d+)?)(-[a-z0-9-]+)?/gi)) {
314
+ if (match[0] !== expectedFrom) {
315
+ violations.push(
316
+ `${path.basename(dockerfilePath)}: expected ${expectedFrom}, got ${match[0]}`,
317
+ )
318
+ }
319
+ }
320
+ }
321
+
322
+ const jenkinsfilePath = path.join(repoRoot, 'Jenkinsfile')
323
+ const expectedJenkins = jenkinsImage ?? deriveJenkinsImageTag(nvmrc)
324
+ if (fs.existsSync(jenkinsfilePath)) {
325
+ const jenkinsfile = fs.readFileSync(jenkinsfilePath, 'utf8')
326
+ const matches = [...jenkinsfile.matchAll(/feelgoodsvenska\/jenkins-build:(node\d+)/g)]
327
+ for (const match of matches) {
328
+ if (match[1] !== expectedJenkins) {
329
+ violations.push(`Jenkinsfile: expected jenkins-build:${expectedJenkins}, got jenkins-build:${match[1]}`)
330
+ }
331
+ }
332
+ }
333
+
334
+ const major = nvmrc.split('.')[0]
335
+ for (const workflowPath of findGithubWorkflows(repoRoot)) {
336
+ const workflow = fs.readFileSync(workflowPath, 'utf8')
337
+ for (const match of workflow.matchAll(/node-version:\s*['"]?(\d+(?:\.\d+)?)['"]?/g)) {
338
+ if (match[1].split('.')[0] !== major) {
339
+ violations.push(
340
+ `${path.relative(repoRoot, workflowPath)}: expected node-version ${major}, got ${match[1]}`,
341
+ )
342
+ }
343
+ }
344
+ }
345
+
346
+ return violations
347
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Sync pnpm packageManager and pnpm@ version pins in a repository.
3
+ */
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+
8
+ import { extractPnpmVersion } from './dependency-policy-toolchain.mjs'
9
+
10
+ const PNPM_AT_VERSION_PATTERN = /pnpm@\d+\.\d+\.\d+(-[\w.]+)?/g
11
+ const PNPM_AT_LATEST_PATTERN = /pnpm@latest/gi
12
+
13
+ const SKIP_DIR_NAMES = new Set([
14
+ 'node_modules',
15
+ '.git',
16
+ 'dist',
17
+ 'build',
18
+ '.next',
19
+ 'coverage',
20
+ 'storybook-static',
21
+ ])
22
+
23
+ /**
24
+ * @param {string} content
25
+ * @param {string} pnpmVersion Bare semver (no pnpm@ prefix).
26
+ * @returns {{ content: string, changed: boolean }}
27
+ */
28
+ export function syncPnpmVersionInContent(content, pnpmVersion) {
29
+ const target = `pnpm@${pnpmVersion}`
30
+ let changed = false
31
+
32
+ let updated = content.replace(PNPM_AT_VERSION_PATTERN, (match) => {
33
+ if (match !== target) {
34
+ changed = true
35
+ }
36
+ return target
37
+ })
38
+
39
+ updated = updated.replace(PNPM_AT_LATEST_PATTERN, () => {
40
+ changed = true
41
+ return target
42
+ })
43
+
44
+ return { content: updated, changed }
45
+ }
46
+
47
+ /**
48
+ * @param {string} content
49
+ * @param {string} pnpmVersion
50
+ * @returns {{ content: string, changed: boolean }}
51
+ */
52
+ export function syncDockerfilePnpmVersion(content, pnpmVersion) {
53
+ return syncPnpmVersionInContent(content, pnpmVersion)
54
+ }
55
+
56
+ /**
57
+ * @param {string} filePath
58
+ * @returns {boolean}
59
+ */
60
+ function shouldScanFileForPnpmPins(filePath) {
61
+ const base = path.basename(filePath)
62
+ if (base === 'package.json' || base === 'pnpm-lock.yaml') {
63
+ return false
64
+ }
65
+ if (base === 'Dockerfile' || base === 'Dockerfile.local') {
66
+ return true
67
+ }
68
+ if (base === 'Jenkinsfile') {
69
+ return true
70
+ }
71
+ if (
72
+ filePath.includes(`${path.sep}.github${path.sep}workflows${path.sep}`) &&
73
+ /\.(ya?ml)$/i.test(base)
74
+ ) {
75
+ return true
76
+ }
77
+ return base.endsWith('.sh')
78
+ }
79
+
80
+ /**
81
+ * @param {string} dir
82
+ * @param {string[]} acc
83
+ * @returns {string[]}
84
+ */
85
+ function walkRepoFiles(dir, acc = []) {
86
+ for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
87
+ if (SKIP_DIR_NAMES.has(item.name)) continue
88
+ const filePath = path.join(dir, item.name)
89
+ if (item.isDirectory()) {
90
+ walkRepoFiles(filePath, acc)
91
+ continue
92
+ }
93
+ acc.push(filePath)
94
+ }
95
+ return acc
96
+ }
97
+
98
+ /**
99
+ * @param {string} repoDir
100
+ * @param {string} packageManager
101
+ * @returns {string[]}
102
+ */
103
+ export function findPnpmVersionMismatchesInRepo(repoDir, packageManager) {
104
+ const pnpmVersion = extractPnpmVersion(packageManager)
105
+ if (!pnpmVersion) {
106
+ return []
107
+ }
108
+
109
+ const target = `pnpm@${pnpmVersion}`
110
+ const violations = []
111
+
112
+ for (const filePath of walkRepoFiles(repoDir)) {
113
+ const rel = path.relative(repoDir, filePath).split(path.sep).join('/')
114
+ const base = path.basename(filePath)
115
+
116
+ if (base === 'package.json') {
117
+ try {
118
+ const pkg = JSON.parse(fs.readFileSync(filePath, 'utf8'))
119
+ if (pkg.packageManager !== undefined && pkg.packageManager !== packageManager) {
120
+ violations.push(`${rel} packageManager: expected ${packageManager}, got ${pkg.packageManager}`)
121
+ }
122
+ } catch {
123
+ // ignore
124
+ }
125
+ continue
126
+ }
127
+
128
+ if (!shouldScanFileForPnpmPins(filePath)) {
129
+ continue
130
+ }
131
+
132
+ const content = fs.readFileSync(filePath, 'utf8')
133
+ if (!/pnpm@(?:\d+\.\d+\.\d+(?:-[\w.]+)?|latest)/i.test(content)) {
134
+ continue
135
+ }
136
+
137
+ for (const match of content.matchAll(/pnpm@(?:\d+\.\d+\.\d+(?:-[\w.]+)?|latest)/gi)) {
138
+ if (match[0] !== target) {
139
+ violations.push(`${rel}: expected ${target}, got ${match[0]}`)
140
+ }
141
+ }
142
+ }
143
+
144
+ return violations
145
+ }
146
+
147
+ /**
148
+ * @param {string} repoDir
149
+ * @param {string} packageManager e.g. pnpm@11.8.0
150
+ * @param {{ dryRun?: boolean }} [options]
151
+ * @returns {string[]} change descriptions
152
+ */
153
+ export function syncPnpmToolchainInRepo(repoDir, packageManager, options = {}) {
154
+ const { dryRun = false } = options
155
+ const pnpmVersion = extractPnpmVersion(packageManager)
156
+ if (!pnpmVersion) {
157
+ throw new Error(`Invalid packageManager: ${packageManager}`)
158
+ }
159
+
160
+ const changes = []
161
+
162
+ for (const filePath of walkRepoFiles(repoDir)) {
163
+ const rel = path.relative(repoDir, filePath).split(path.sep).join('/')
164
+ const base = path.basename(filePath)
165
+
166
+ if (base === 'package.json') {
167
+ let pkg
168
+ try {
169
+ pkg = JSON.parse(fs.readFileSync(filePath, 'utf8'))
170
+ } catch {
171
+ continue
172
+ }
173
+ if (pkg.packageManager !== undefined && pkg.packageManager !== packageManager) {
174
+ changes.push(`${rel} packageManager: ${pkg.packageManager} -> ${packageManager}`)
175
+ if (!dryRun) {
176
+ pkg.packageManager = packageManager
177
+ fs.writeFileSync(filePath, `${JSON.stringify(pkg, null, 2)}\n`)
178
+ }
179
+ }
180
+ continue
181
+ }
182
+
183
+ if (!shouldScanFileForPnpmPins(filePath)) {
184
+ continue
185
+ }
186
+
187
+ const content = fs.readFileSync(filePath, 'utf8')
188
+ if (!/pnpm@(?:\d+\.\d+\.\d+(?:-[\w.]+)?|latest)/i.test(content)) {
189
+ continue
190
+ }
191
+
192
+ const { content: updated, changed } = syncPnpmVersionInContent(content, pnpmVersion)
193
+ if (changed) {
194
+ changes.push(`${rel}: pnpm -> ${pnpmVersion}`)
195
+ if (!dryRun) {
196
+ fs.writeFileSync(filePath, updated)
197
+ }
198
+ }
199
+ }
200
+
201
+ return changes
202
+ }
203
+
204
+ /**
205
+ * @param {Record<string, unknown>} pkg
206
+ * @returns {boolean}
207
+ */
208
+ export function isVueFgDevkitConsumerPackage(pkg) {
209
+ if (!pkg || pkg.name === 'fg-devkit') {
210
+ return false
211
+ }
212
+ const hasVue = Boolean(pkg.dependencies?.vue || pkg.devDependencies?.vue)
213
+ const hasFgDevkit = Boolean(pkg.dependencies?.['fg-devkit'] || pkg.devDependencies?.['fg-devkit'])
214
+ return hasVue && hasFgDevkit
215
+ }
216
+
217
+ /**
218
+ * @param {string} repoDir
219
+ * @returns {boolean}
220
+ */
221
+ export function isVueFgDevkitConsumerRepo(repoDir) {
222
+ const pkgPath = path.join(repoDir, 'package.json')
223
+ if (!fs.existsSync(pkgPath)) {
224
+ return false
225
+ }
226
+ try {
227
+ return isVueFgDevkitConsumerPackage(JSON.parse(fs.readFileSync(pkgPath, 'utf8')))
228
+ } catch {
229
+ return false
230
+ }
231
+ }
@@ -10,7 +10,7 @@
10
10
  * --slug <string> package.json name / slug (default: my-app)
11
11
  * --docker-image <string> Docker registry image without tag (default: myorg/my-app-ui)
12
12
  * --jenkins-library <string> Jenkins shared library name (default: my-shared-library)
13
- * --jenkins-node-image <string> CI Docker image for Node (default: node:22-bookworm)
13
+ * --jenkins-node-image <string> CI Docker image for Node (default: node:22-alpine)
14
14
  * --jenkins-docker-label <string> Jenkins label for docker-build agents (default: docker)
15
15
  * --jenkins-registry-cred <string> Jenkins credentialsId for Docker registry (default: docker-registry)
16
16
  * --jenkins-poeditor-cred <string> Jenkins credentialsId for POEditor API key (default: poeditor_api_key)
@@ -146,7 +146,7 @@ function main() {
146
146
  const slug = flags.get('slug') ?? 'my-app'
147
147
  const dockerImage = flags.get('docker-image') ?? 'myorg/my-app-ui'
148
148
  const jenkinsLibrary = flags.get('jenkins-library') ?? 'my-shared-library'
149
- const jenkinsNodeImage = flags.get('jenkins-node-image') ?? 'node:22-bookworm'
149
+ const jenkinsNodeImage = flags.get('jenkins-node-image') ?? 'node:22-alpine'
150
150
  const jenkinsDockerLabel = flags.get('jenkins-docker-label') ?? 'docker'
151
151
  const jenkinsRegistryCred = flags.get('jenkins-registry-cred') ?? 'docker-registry'
152
152
  const jenkinsPoeditorCred = flags.get('jenkins-poeditor-cred') ?? 'poeditor_api_key'
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Only updates keys that already exist in package.json (does not add new dependencies from the policy).
6
6
  * Aligns managedDependencies, managedAnywhere (only where the package is already declared), optional
7
- * policy overrides into pnpm.overrides, toolchain.packageManager into package.json, and logs each change.
7
+ * policy overrides into pnpm.overrides, toolchain fields (packageManager, Node), and logs each change.
8
8
  *
9
9
  * Refuses to run if the policy file contains any non-pinned versions.
10
10
  *
@@ -17,15 +17,19 @@ import { fileURLToPath } from 'node:url'
17
17
 
18
18
  import { collectPinViolations } from './dependency-policy-pins.mjs'
19
19
  import {
20
+ collectNodeToolchainPolicyViolations,
20
21
  collectToolchainPolicyViolations,
21
22
  resolveExpectedPackageManager,
22
23
  } from './dependency-policy-toolchain.mjs'
24
+ import { syncNodeToolchainInRepo } from './node-toolchain-sync.mjs'
25
+ import { syncPnpmToolchainInRepo } from './pnpm-toolchain-sync.mjs'
23
26
  import { resolveDependencyPolicyPath } from './resolve-dependency-policy-path.mjs'
24
27
 
25
28
  const cwd = process.cwd()
26
29
  const pkgPath = path.resolve(cwd, 'package.json')
27
30
  const lockfilePath = path.resolve(cwd, 'pnpm-lock.yaml')
28
31
  const scriptDir = path.dirname(fileURLToPath(import.meta.url))
32
+ const fgDevkitRoot = path.resolve(scriptDir, '..')
29
33
  const policyPath = resolveDependencyPolicyPath(scriptDir, cwd, process.argv)
30
34
 
31
35
  if (!fs.existsSync(pkgPath)) {
@@ -49,6 +53,7 @@ const policyErrors = [
49
53
  ),
50
54
  ...collectPinViolations('managedAnywhere.', policy.managedAnywhere ?? {}),
51
55
  ...collectToolchainPolicyViolations(policy),
56
+ ...collectNodeToolchainPolicyViolations(policy),
52
57
  ]
53
58
 
54
59
  if (policyErrors.length > 0) {
@@ -130,30 +135,57 @@ for (const [name, version] of Object.entries(policy.managedAnywhere ?? {})) {
130
135
  }
131
136
  }
132
137
 
133
- pkg.pnpm ||= {}
134
- pkg.pnpm.overrides ||= {}
135
-
136
138
  // Apply policy-defined override versions when present in the policy.
137
- for (const [name, version] of Object.entries(getRelevantPolicyOverrides(policy.overrides ?? {}))) {
138
- if (pkg.pnpm.overrides[name] !== version) {
139
- changes.push(`pnpm.overrides.${name}: ${pkg.pnpm.overrides[name] ?? '(missing)'} -> ${version}`)
140
- pkg.pnpm.overrides[name] = version
139
+ const relevantOverrides = getRelevantPolicyOverrides(policy.overrides ?? {})
140
+ for (const [name, version] of Object.entries(relevantOverrides)) {
141
+ if (pkg.pnpm?.overrides?.[name] === version) {
142
+ continue
141
143
  }
144
+ pkg.pnpm ??= {}
145
+ pkg.pnpm.overrides ??= {}
146
+ changes.push(`pnpm.overrides.${name}: ${pkg.pnpm.overrides[name] ?? '(missing)'} -> ${version}`)
147
+ pkg.pnpm.overrides[name] = version
142
148
  }
143
149
 
144
- const expectedPackageManager = resolveExpectedPackageManager(policy, path.resolve(scriptDir, '..'))
150
+ const expectedPackageManager = resolveExpectedPackageManager(policy, fgDevkitRoot)
145
151
  if (expectedPackageManager !== undefined && pkg.packageManager !== expectedPackageManager) {
146
152
  changes.push(`packageManager: ${pkg.packageManager ?? '(missing)'} -> ${expectedPackageManager}`)
147
153
  pkg.packageManager = expectedPackageManager
148
154
  }
149
155
 
150
- if (changes.length === 0) {
151
- console.log('[sync-deps] package.json already matches dependency policy')
156
+ if (changes.length > 0) {
157
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
158
+ }
159
+
160
+ const nodeToolchainChanges = syncNodeToolchainInRepo(cwd, policy, { fgDevkitRoot })
161
+
162
+ let pnpmToolchainChanges = []
163
+ if (expectedPackageManager !== undefined) {
164
+ pnpmToolchainChanges = syncPnpmToolchainInRepo(cwd, expectedPackageManager)
165
+ }
166
+
167
+ if (changes.length === 0 && nodeToolchainChanges.length === 0 && pnpmToolchainChanges.length === 0) {
168
+ console.log('[sync-deps] package.json and toolchain already match dependency policy')
152
169
  process.exit(0)
153
170
  }
154
171
 
155
- fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
156
- console.log('[sync-deps] Updated package.json to match dependency policy')
157
- for (const change of changes) {
158
- console.log(`- ${change}`)
172
+ if (changes.length > 0) {
173
+ console.log('[sync-deps] Updated package.json to match dependency policy')
174
+ for (const change of changes) {
175
+ console.log(`- ${change}`)
176
+ }
177
+ }
178
+
179
+ if (nodeToolchainChanges.length > 0) {
180
+ console.log('[sync-deps] Updated Node toolchain files')
181
+ for (const change of nodeToolchainChanges) {
182
+ console.log(`- ${change}`)
183
+ }
184
+ }
185
+
186
+ if (pnpmToolchainChanges.length > 0) {
187
+ console.log('[sync-deps] Updated pnpm toolchain files')
188
+ for (const change of pnpmToolchainChanges) {
189
+ console.log(`- ${change}`)
190
+ }
159
191
  }