@leverege/build-tools 2.124.1 → 2.126.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leverege/build-tools",
3
- "version": "2.124.1",
3
+ "version": "2.126.1",
4
4
  "description": "A collection of build / support tools for Leverege developers",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -102,21 +102,21 @@
102
102
  "execa": "^10.0.1",
103
103
  "find-yarn-workspace-root": "^2.0.0",
104
104
  "glob": "^13.0.6",
105
- "googleapis": "^181.0.0",
105
+ "googleapis": "^182.0.0",
106
106
  "handlebars": "^4.7.9",
107
- "ignore": "^7.0.9",
107
+ "ignore": "^7.0.10",
108
108
  "inquirer": "^14.2.2",
109
109
  "js-yaml": "^4.3.2",
110
110
  "jsdoc": "^4.0.5",
111
111
  "ms": "^2.1.3",
112
112
  "npm-registry-fetch": "^20.0.1",
113
113
  "ora": "^9.4.1",
114
- "p-limit": "^7.3.2",
114
+ "p-limit": "^7.3.3",
115
115
  "package-up": "^5.0.0",
116
116
  "readline-sync": "^1.4.10",
117
117
  "semver": "^7.8.5",
118
118
  "shell-quote": "^1.10.0",
119
- "simple-git": "^3.36.0",
119
+ "simple-git": "^4.0.2",
120
120
  "sloc": "^0.3.2",
121
121
  "superstruct": "^2.0.2",
122
122
  "tmp": "^0.2.7",
@@ -125,11 +125,11 @@
125
125
  "zx": "^8.8.5"
126
126
  },
127
127
  "devDependencies": {
128
- "@leverege/eslint-config-leverege": "^9",
128
+ "@leverege/eslint-config-leverege": "^10",
129
129
  "chai": "^6.2.2",
130
- "eslint": "^9",
131
- "mocha": "^12.0.1",
132
- "npm": "^12.0.2"
130
+ "eslint": "^10",
131
+ "mocha": "^12.0.2",
132
+ "npm": "^12.1.0"
133
133
  },
134
134
  "allowScripts": {
135
135
  "unrs-resolver@1.12.2": true
package/src/Utils.mjs CHANGED
@@ -322,7 +322,7 @@ export const parseYamlFile = async ( filePath ) => {
322
322
  const fileContent = await fs.promises.readFile( filePath, 'utf8' ) /* eslint-disable-line security/detect-non-literal-fs-filename */
323
323
  return YAML.load( fileContent )
324
324
  } catch ( error ) {
325
- throw new Error( `Failed to parse YAML file ${filePath}: ${error.message}` )
325
+ throw new Error( `Failed to parse YAML file ${filePath}: ${error.message}`, { cause : error } )
326
326
  }
327
327
  }
328
328
 
@@ -334,7 +334,7 @@ export const parseJsonFile = async ( jsonFile ) => {
334
334
  const json = JSON.parse( fs.readFileSync( jsonFile, 'utf8' ) )
335
335
  return json
336
336
  } catch ( error ) {
337
- throw new Error( `${error} in ${jsonFile}` )
337
+ throw new Error( `${error} in ${jsonFile}`, { cause : error } )
338
338
  }
339
339
  }
340
340
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * chart-to-registry will use a chart
3
+ * chart-to-registry will use a chart
4
4
  */
5
5
  import fs from 'node:fs'
6
6
 
@@ -3,6 +3,7 @@
3
3
  import { $ } from 'zx'
4
4
  import { program } from 'commander'
5
5
  import chalk from 'chalk'
6
+ import semver from 'semver'
6
7
 
7
8
  import { enableDebug, errorExit, log } from './Utils.mjs'
8
9
 
@@ -10,14 +11,21 @@ $.verbose = false
10
11
 
11
12
  const DEFAULT_OWNER = 'Leverege'
12
13
 
13
- const COMMENT = (
14
+ const GROUPED_COMMENT = (
14
15
  'Closing in favor of the new grouped Dependabot config (patch/minor ' +
15
16
  'updates now batch into a single PR). Not a rejection of this specific ' +
16
17
  'bump - Dependabot will re-propose it as part of a grouped PR on its ' +
17
18
  'next scan.'
18
19
  )
19
20
 
21
+ const SATISFIED_COMMENT = (
22
+ 'Closing because the target branch already declares a version that ' +
23
+ 'satisfies this bump (it was applied through a different PR). Not a ' +
24
+ 'rejection of this specific bump.'
25
+ )
26
+
20
27
  const TITLE_RE = /^Bump (\S+) from (\S+) to (\S+)$/
28
+ const DEP_SECTIONS = [ 'dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies' ]
21
29
 
22
30
  program
23
31
  .name( 'close-dependabot-prs' )
@@ -25,12 +33,20 @@ program
25
33
  .argument( '<repos...>', 'repo names - bare name uses --owner, or pass owner/repo to override' )
26
34
  .option( '--owner <name>', 'GitHub org/owner for bare repo names', DEFAULT_OWNER )
27
35
  .option( '--dry-run', 'show what would be closed without closing anything' )
36
+ .option(
37
+ '--close-satisfied',
38
+ 'also CLOSE PRs (any bump type, including major) whose proposed version is ' +
39
+ 'already satisfied by the target branch\'s package.json - e.g. a manual bump ' +
40
+ 'landed through a different PR while this one sat stale. Without this flag, ' +
41
+ 'such PRs are only reported as a "hint" line, never closed'
42
+ )
28
43
  .option( '--debug', 'Enable debug logging' )
29
44
  .addHelpText( 'after', `
30
45
  ${chalk.yellow( 'Examples:' )}
31
46
  ${chalk.green( 'close-dependabot-prs array-util base62-util' )}
32
47
  ${chalk.green( 'close-dependabot-prs --dry-run cache' )}
33
48
  ${chalk.green( 'close-dependabot-prs --owner SomeOtherOrg some-repo' )}
49
+ ${chalk.green( 'close-dependabot-prs --close-satisfied --dry-run overdose' )}
34
50
 
35
51
  ${chalk.yellow( 'Notes:' )}
36
52
  Closes open Dependabot PRs that are superseded by a newly-added groups:
@@ -39,7 +55,21 @@ ${chalk.yellow( 'Notes:' )}
39
55
  closing them risks Dependabot never re-proposing that exact version.
40
56
 
41
57
  Skips any repo whose dependabot.yml has no groups: block at all - closing
42
- PRs there would lose them rather than get them re-proposed.
58
+ PRs there would lose them rather than get them re-proposed - unless
59
+ --close-satisfied finds a reason to close a specific PR anyway.
60
+
61
+ --close-satisfied handles a different, unrelated situation: a PR (of any
62
+ bump type) that Dependabot never auto-closed as superseded because it sat
63
+ idle past GitHub's 30-day auto-rebase cutoff, even though the dependency
64
+ was already bumped to (or past) the PR's target version through a separate
65
+ PR. This is checked with real semver comparison against the target
66
+ branch's declared range, so it is safe for major bumps too - if the base
67
+ branch's range doesn't already guarantee the proposed version, the PR is
68
+ left alone. Always sanity-check with --dry-run first.
69
+
70
+ This "already satisfied" check always runs, even without --close-satisfied
71
+ - a plain run will surface a "hint" line for any PR it finds satisfied, so
72
+ you know --close-satisfied is worth trying instead of guessing.
43
73
 
44
74
  Requires the gh CLI, authenticated.
45
75
  ` )
@@ -90,20 +120,51 @@ async function openDependabotPrs( repo ) {
90
120
  return JSON.parse( stdout )
91
121
  }
92
122
 
93
- async function processRepo( repo, dryRun ) {
123
+ async function fetchPackageJson( repo ) {
124
+ let stdout
125
+ try {
126
+ ( { stdout } = await $`gh api repos/${repo}/contents/package.json --jq .content` )
127
+ } catch {
128
+ return null // couldn't fetch - treat as unknown, caller should skip
129
+ }
130
+ try {
131
+ return JSON.parse( Buffer.from( stdout.trim(), 'base64' ).toString( 'utf8' ) )
132
+ } catch {
133
+ return null // unparseable - treat as unknown, caller should skip
134
+ }
135
+ }
136
+
137
+ // Returns true if the target branch's package.json already declares a range for
138
+ // depName whose minimum version satisfies-or-exceeds toV. Returns false if it
139
+ // definitely doesn't. Returns null if this can't be determined safely (dependency
140
+ // missing, range unparseable, etc) - callers should treat null as "don't know".
141
+ function isAlreadySatisfied( pkgJson, depName, toV ) {
142
+ const toVersion = semver.valid( toV )
143
+ if ( toVersion == null ) return null
144
+
145
+ const declaredRange = DEP_SECTIONS
146
+ .map( section => pkgJson[section] && pkgJson[section][depName] )
147
+ .find( range => range != null )
148
+ if ( declaredRange == null ) return null
149
+
150
+ let minCurrent
151
+ try {
152
+ minCurrent = semver.minVersion( declaredRange )
153
+ } catch {
154
+ return null
155
+ }
156
+ if ( minCurrent == null ) return null
157
+
158
+ return semver.gte( minCurrent, toVersion )
159
+ }
160
+
161
+ async function processRepo( repo, opts ) {
94
162
  log( `\n=== ${repo} ===` )
95
163
  const grouped = await hasGroupingConfigured( repo )
96
164
  if ( grouped === null ) {
97
165
  log( ' ! could not read .github/dependabot.yml - skipping' )
98
166
  return
99
167
  }
100
- if ( !grouped ) {
101
- log(
102
- ' ! no `groups:` block found in dependabot.yml - skipping ' +
103
- '(closing PRs here would lose them, not get them re-proposed)'
104
- )
105
- return
106
- }
107
168
 
108
169
  const prs = await openDependabotPrs( repo )
109
170
  if ( prs.length === 0 ) {
@@ -111,7 +172,25 @@ async function processRepo( repo, dryRun ) {
111
172
  return
112
173
  }
113
174
 
175
+ // Always check for already-satisfied PRs (cheap - one extra API call) so we can
176
+ // at least *report* them even when --close-satisfied wasn't passed. Acting on
177
+ // them still requires the flag; finding them shouldn't.
178
+ const pkgJson = await fetchPackageJson( repo )
179
+ if ( pkgJson == null ) {
180
+ log( ' ! package.json could not be read/parsed - the "already satisfied" check will be skipped' )
181
+ }
182
+
183
+ if ( !grouped && pkgJson == null ) {
184
+ log(
185
+ ' ! no `groups:` block found in dependabot.yml - skipping ' +
186
+ '(closing PRs here would lose them, not get them re-proposed)'
187
+ )
188
+ return
189
+ }
190
+
114
191
  let closedCount = 0
192
+ let closedSatisfiedCount = 0
193
+ let hintCount = 0
115
194
  let keptCount = 0
116
195
  let skippedCount = 0
117
196
 
@@ -122,14 +201,37 @@ async function processRepo( repo, dryRun ) {
122
201
  log( ` skip #${pr.number} (title didn't match expected pattern) ${pr.title}` )
123
202
  continue
124
203
  }
125
- const [ , , fromV, toV ] = m
204
+ const [ , depName, , toV ] = m
205
+
206
+ if ( pkgJson != null && isAlreadySatisfied( pkgJson, depName, toV ) === true ) {
207
+ if ( opts.closeSatisfied ) {
208
+ closedSatisfiedCount++
209
+ log( ` close #${String( pr.number ).padEnd( 4 )} satisfied ${pr.title}` )
210
+ if ( !opts.dryRun ) {
211
+ // eslint-disable-next-line no-await-in-loop
212
+ await $`gh pr close ${pr.number} --repo ${repo} --comment ${SATISFIED_COMMENT}`
213
+ }
214
+ } else {
215
+ hintCount++
216
+ log( ` hint #${String( pr.number ).padEnd( 4 )} satisfied ${pr.title} (rerun with --close-satisfied to close)` )
217
+ }
218
+ continue
219
+ }
220
+
221
+ if ( !grouped ) {
222
+ keptCount++
223
+ log( ` keep #${String( pr.number ).padEnd( 4 )} ungrouped ${pr.title}` )
224
+ continue
225
+ }
226
+
227
+ const [ , , fromV ] = m
126
228
  const bumpType = classifyBump( fromV, toV )
127
229
  if ( bumpType === 'patch' || bumpType === 'minor' ) {
128
230
  closedCount++
129
231
  log( ` close #${String( pr.number ).padEnd( 4 )} ${bumpType.padEnd( 7 )} ${pr.title}` )
130
- if ( !dryRun ) {
232
+ if ( !opts.dryRun ) {
131
233
  // eslint-disable-next-line no-await-in-loop
132
- await $`gh pr close ${pr.number} --repo ${repo} --comment ${COMMENT}`
234
+ await $`gh pr close ${pr.number} --repo ${repo} --comment ${GROUPED_COMMENT}`
133
235
  }
134
236
  } else {
135
237
  keptCount++
@@ -137,12 +239,16 @@ async function processRepo( repo, dryRun ) {
137
239
  }
138
240
  }
139
241
 
140
- log( ` -> ${closedCount} closed, ${keptCount} left open (major), ${skippedCount} skipped` )
242
+ log(
243
+ ` -> ${closedCount} closed (grouped), ${closedSatisfiedCount} closed (satisfied), ` +
244
+ `${hintCount} satisfied but not closed (see --close-satisfied), ` +
245
+ `${keptCount} left open, ${skippedCount} skipped`
246
+ )
141
247
  }
142
248
 
143
249
  // ---------- Run ----------
144
250
 
145
251
  for ( const repo of repos ) {
146
252
  // eslint-disable-next-line no-await-in-loop
147
- await processRepo( repo, opts.dryRun )
253
+ await processRepo( repo, opts )
148
254
  }
@@ -76,18 +76,29 @@ ${chalk.bold( 'Environment Variables (optional)' )}
76
76
 
77
77
  ${chalk.bold( 'REFRESH_NPM_PROJECT' )} ${chalk.green( 'may be used in lieu of --project' )}
78
78
 
79
- ${chalk.bold( 'Manually Bootstraping the Token' )}
79
+ ${chalk.bold( 'Manually Bootstrapping the Token' )}
80
80
  ${chalk.green( `
81
- There may be times when a token is expired, lost or a new developer needs
82
- to seed their ~/.npmrc file for the first time. Help a brother out and let
83
- them know to run this command to fetch the current npmrc file:
84
-
81
+ There may be times when a token is expired, lost, or a new developer needs
82
+ to seed their ~/.npmrc for the first time. Because the REFRESH_NPM_NPMRC
83
+ secret is a template (not a rendered file), you must fetch the token and
84
+ substitute it in one step. Run the following — requires only gcloud and a
85
+ valid gcloud auth login:
86
+
87
+ TOKEN=$(gcloud secrets versions access latest \\
88
+ --secret=REFRESH_NPM_TOKEN \\
89
+ --project=leverege-registry) && \\
85
90
  gcloud secrets versions access latest \\
86
- --secret=REFRESH_NPM_NPMRC \\
87
- --project=leverege-registry > ~/.npmrc
91
+ --secret=REFRESH_NPM_NPMRC \\
92
+ --project=leverege-registry | \\
93
+ sed "s/\\\${REFRESH_NPM_TOKEN}/\${TOKEN}/g" > ~/.npmrc
94
+
95
+ Once ~/.npmrc is seeded with a valid token, install build-tools and let it
96
+ manage the rc files going forward:
97
+
98
+ npm install -g @leverege/build-tools
99
+ refresh-npm-token --force
88
100
 
89
- Note, ^^that command will need a different --project name for non-leverege
90
- developers.` )}
101
+ Note: the --project flag above may differ for non-leverege environments.` )}
91
102
  ` )
92
103
  .parse()
93
104
 
@@ -8,7 +8,7 @@ import chalk from 'chalk'
8
8
  import { glob } from 'glob'
9
9
  import ora from 'ora'
10
10
  import pLimit from 'p-limit'
11
- import simpleGit from 'simple-git'
11
+ import { simpleGit } from 'simple-git'
12
12
  import { $ } from 'zx'
13
13
 
14
14
  import { clear, enableDebug, log, parseJsonFile } from './Utils.mjs'
@@ -23,6 +23,7 @@ const SYM_BAD = chalk.red( '✘' )
23
23
  // Add new checks here — the table expands automatically.
24
24
 
25
25
  let latestOrbVersion = null
26
+ const pulledRoots = new Set()
26
27
 
27
28
  const CHECKS = [
28
29
  {
@@ -194,18 +195,19 @@ async function checkRepo( dir, { pull } ) {
194
195
  const pkgVer = parseVersion( pkg?.version )
195
196
 
196
197
  let syncOk = true
197
- if ( isWorkspace ) {
198
+ if ( pull && !pulledRoots.has( gitRoot ) ) {
199
+ pulledRoots.add( gitRoot )
198
200
  try {
199
- const relDir = path.relative( gitRoot, dir )
200
- const status = await git.status( [ '--', relDir ] )
201
- syncOk = status.files.length === 0
201
+ await git.pull()
202
+ await git.fetch( [ '--tags' ] )
202
203
  } catch {
203
204
  syncOk = false
204
205
  }
205
- } else if ( pull ) {
206
+ } else if ( isWorkspace ) {
206
207
  try {
207
- await git.pull()
208
- await git.fetch( [ '--tags' ] )
208
+ const relDir = path.relative( gitRoot, dir )
209
+ const status = await git.status( [ '--', relDir ] )
210
+ syncOk = status.files.length === 0
209
211
  } catch {
210
212
  syncOk = false
211
213
  }
@@ -70,8 +70,8 @@ if ( args._unknown ) {
70
70
  }
71
71
  /* eslint-enable no-underscore-dangle */
72
72
 
73
- if ( semverLt( process.version, '18.0.0' ) ) {
74
- log( chalk.red( '\n\n***ERROR: must be running at least node 18\n' ) )
73
+ if ( semverLt( process.version, '24.0.0' ) ) {
74
+ log( chalk.red( '\n\n***ERROR: must be running at least node 24\n' ) )
75
75
  process.exit( 1 )
76
76
  }
77
77
 
@@ -104,7 +104,7 @@ const slackData = {
104
104
  channel : slackConfig.channel,
105
105
  }
106
106
 
107
- if ( process.env.BUILD_TOOLS_DEBUG || args.dryRun ) {
107
+ if ( process.env.BUILD_TOOLS_DEBUG || args.dryRun ) { // || process.env.SKIP_SEND_TO_SLACK ) {
108
108
  const account = await shellCmd( 'gcloud config get account' )
109
109
  log( { account, slackConfig, slackData }, '<==Slack skipped' )
110
110
  } else {
@@ -4,7 +4,7 @@ import fs from 'node:fs/promises' /* eslint-disable security/detect-non-literal-
4
4
  import chalk from 'chalk'
5
5
  import { execa } from 'execa'
6
6
  import inquirer from 'inquirer'
7
- import SimpleGit from 'simple-git'
7
+ import { simpleGit as SimpleGit } from 'simple-git'
8
8
 
9
9
  import { log, } from '../../Utils.mjs'
10
10
 
@@ -12,7 +12,7 @@ export default class Config {
12
12
  constructor( options ) {
13
13
  // we cannot async in the constructor - so use init to do the rest
14
14
  this.configRoot = `${options.sourceTop}/config`
15
- this.git = new SimpleGit()
15
+ this.git = SimpleGit()
16
16
  }
17
17
 
18
18
  // expects a directory name containing text files with a .json extension
@@ -42,8 +42,7 @@ export default class SecretsManager {
42
42
  try {
43
43
  await shellCmd( `gcloud secrets describe ${key} --project ${project}` )
44
44
  exists = true
45
- } catch ( err ) { // eslint-disable-line no-unused-vars
46
- exists = false
45
+ } catch {
47
46
  }
48
47
 
49
48
  if ( exists ) {