@juit/check-updates 2.0.7 → 3.0.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.
package/src/updater.ts CHANGED
@@ -1,212 +1,366 @@
1
+ import assert from 'node:assert'
2
+ import { EventEmitter } from 'node:events'
1
3
  import { readFile, writeFile } from 'node:fs/promises'
4
+ import { relative, resolve } from 'node:path'
2
5
 
3
- import * as glob from 'glob'
4
6
  import semver from 'semver'
5
- import fetch from 'npm-registry-fetch'
6
7
 
8
+
9
+ import { B, G, R, X, Y, makeDebug } from './debug'
7
10
  import { readNpmRc } from './npmrc'
8
11
 
9
12
  import type { ReleaseType } from 'semver'
13
+ import type { VersionsCache } from './versions'
10
14
 
11
15
  export type UpdaterOptions = {
12
- bump?: ReleaseType,
13
- strict?: boolean,
14
- quick?: boolean,
15
- debug?: boolean,
16
- dryrun?: boolean,
16
+ bump: ReleaseType | undefined,
17
+ debug: boolean,
18
+ quick: boolean,
19
+ strict: boolean,
20
+ workspaces: boolean,
21
+ }
22
+
23
+ const dependencyTypes = [
24
+ 'dependencies',
25
+ 'devDependencies',
26
+ 'peerDependencies',
27
+ 'optionalDependencies',
28
+ ] as const
29
+
30
+ type DependencyType = (typeof dependencyTypes)[number]
31
+
32
+ interface PackageData {
33
+ name?: string,
34
+ version?: string,
35
+ dependencies?: Record<string, string>,
36
+ devDependencies?: Record<string, string>,
37
+ peerDependencies?: Record<string, string>,
38
+ optionalDependencies?: Record<string, string>,
17
39
  }
18
40
 
19
- interface Change {
41
+ interface DependencyChange {
20
42
  name: string,
21
- from: string,
22
- to: string,
23
- kind: string,
43
+ declared: string,
44
+ updated: string,
45
+ type: DependencyType,
24
46
  }
25
47
 
26
- /* Our packages cache version */
27
- const cache: Record<string, Promise<string[]>> = {}
28
-
29
- /* Colors */
30
- const [ K, R, G, Y, B ] = [ 0, 31, 32, 33, 34 ].map((x) => `\u001b[${x}m`)
31
-
32
- /* ========================================================================== *
33
- * Process a number of package files one by one *
34
- * ========================================================================== */
35
- export async function processPackages(
36
- patterns: string | string[],
37
- options: UpdaterOptions,
38
- ): Promise<number> {
39
- /* Destructure our options */
40
- const { bump, quick, strict, debug, dryrun } = options
41
-
42
- /* ------------------------------------------------------------------------ *
43
- * A super-simple debug function *
44
- * ------------------------------------------------------------------------ */
45
- function $debug(...args: string[]): void {
46
- if (debug && args) console.log(`${R}[DEBUG]${K}`, ...args)
47
- }
48
-
49
- /* ------------------------------------------------------------------------ *
50
- * Download (or return cached) versions for a package, greatest first *
51
- * (we're upgrading, ainnit?) without any prerelease *
52
- * ------------------------------------------------------------------------ */
53
-
54
- function getVersions(name: string, npmrc: Record<string, any>): Promise<string[]> {
55
- if (name in cache) {
56
- $debug(`Returning cached versions for ${Y}${name}${K}`)
57
- return cache[name] as Promise<string[]>
48
+ class Workspaces {
49
+ private _versions: Record<string, string> = {}
50
+ private _emitter = new EventEmitter()
51
+
52
+ get length(): number {
53
+ return Object.entries(this._versions).length
54
+ }
55
+
56
+ onUpdate(handler: (name: string, version: string) => void): void {
57
+ this._emitter.on('update', handler)
58
+ }
59
+
60
+ register(name: string, version?: string): void {
61
+ assert(! this._versions[name], `Package "${name}" already registered`)
62
+ this._versions[name] = version || '0.0.0'
63
+ }
64
+
65
+ update(name: string, version: string): void {
66
+ assert(this._versions[name], `Package "${name}" not registered`)
67
+ const oldVersion = this._versions[name] || '0.0.0'
68
+ assert(semver.gte(version, oldVersion), `Package "${name}" new version ${version} less than old ${oldVersion}`)
69
+ if (semver.eq(version, oldVersion)) return
70
+ this._versions[name] = version
71
+ this._emitter.emit('update', name, version)
72
+ }
73
+
74
+ has(name: string): boolean {
75
+ return !! this._versions[name]
76
+ }
77
+
78
+ * [Symbol.iterator](): Generator<[ name: string, version: string ]> {
79
+ for (const [ name, version ] of Object.entries(this._versions)) {
80
+ yield [ name, version ]
58
81
  }
82
+ }
83
+ }
59
84
 
60
- $debug(`Retrieving versions for package ${Y}${name}${K}`)
61
85
 
62
- const range = new semver.Range('>=0.0.0', { includePrerelease: false })
86
+ export class Updater {
87
+ private _packageData?: PackageData
88
+ private _npmRc?: Record<string, any>
89
+ private _originalVersion?: string
90
+
91
+ private _debug: (...args: any[]) => void
92
+ private _children: Updater[]
93
+ private _changed = false
94
+
95
+ constructor(
96
+ private readonly _packageFile: string,
97
+ private readonly _options: UpdaterOptions,
98
+ private readonly _cache: VersionsCache,
99
+ private readonly _workspaces: Workspaces = new Workspaces(),
100
+ ) {
101
+ this._packageFile = resolve(_packageFile)
102
+ this._debug = makeDebug(_options.debug)
103
+ this._children = []
104
+
105
+ _workspaces.onUpdate((name, version) => {
106
+ if (! this._packageData) return
107
+
108
+ for (const type of dependencyTypes) {
109
+ const dependencies = this._packageData[type]
110
+ if (! dependencies) return
111
+ if (! dependencies[name]) return
112
+ if (dependencies[name] === version) return
113
+
114
+ dependencies[name] = version
115
+ this._changed = true
116
+ this._bump()
117
+ }
118
+ })
119
+ }
63
120
 
64
- return cache[name] = fetch.json(name, Object.assign({}, npmrc, { spec: name }))
65
- .then((data: any) => {
66
- return Object.entries(data.versions as Record<string, Record<string, any>>)
67
- .filter(([ , info ]) => ! info.deprecated) // no deprecated
68
- .map(([ version ]) => version) // extract key (version)
69
- .filter((version) => range.test(version)) // range match
70
- .sort(semver.rcompare)
71
- })
121
+ get name(): string | undefined {
122
+ assert(this._packageData, 'Updater not initialized')
123
+ return this._packageData.name
124
+ }
125
+
126
+ get version(): string {
127
+ assert(this._packageData, 'Updater not initialized')
128
+ return this._packageData.version || '0.0.0'
129
+ }
130
+
131
+ set version(version: string) {
132
+ assert(this._originalVersion && this._packageData, 'Updater not initialized')
133
+
134
+ assert(semver.lte(this._originalVersion, version), [
135
+ `Unable to set version for "${this.packageFile}" to "${version}"`,
136
+ `as it's less than original version "${this._originalVersion}"`,
137
+ ].join(' '))
138
+
139
+ if (semver.eq(this._originalVersion, version)) return
140
+ if (this._packageData.version === version) return
141
+
142
+ this._changed = true
143
+ console.log(`Updating ${this._details} version to ${Y}${version}${X}`)
144
+ this._packageData.version = version
145
+ if (this.name) this._workspaces.update(this.name, version)
146
+ }
147
+
148
+ get packageFile(): string {
149
+ return relative(process.cwd(), this._packageFile)
150
+ }
151
+
152
+ get changed(): boolean {
153
+ if (this._changed) return true
154
+ return this._children.reduce((changed, child) => changed || child.changed, false)
155
+ }
156
+
157
+ async init(): Promise<this> {
158
+ this._debug('Reading package file', this.packageFile)
159
+
160
+ /* Parse our package file */
161
+ const json = await readFile(this._packageFile, 'utf8')
162
+ const data = this._packageData = JSON.parse(json)
163
+ assert(data && (typeof data === 'object') && (! Array.isArray(data)),
164
+ `File ${this.packageFile} is not a valid "pacakge.json" file`)
165
+
166
+ /* Parse the ".npmrc" relative to the package file */
167
+ const npmrc = await readNpmRc(this._packageFile)
168
+
169
+ /* Register this package in our workspaces and set the original version */
170
+ if (data.name) this._workspaces.register(data.name, data.version)
171
+ this._originalVersion = data.version || '0.0.0'
172
+
173
+ /* Read up our workspaces */
174
+ if (this._options.workspaces && data.workspaces) {
175
+ for (const path of data.workspaces) {
176
+ const packageFile = resolve(this._packageFile, '..', path, 'package.json')
177
+ const updater = new Updater(packageFile, this._options, this._cache, this._workspaces)
178
+ this._children.push(await updater.init())
179
+ }
180
+ }
181
+
182
+ /* Done */
183
+ this._packageData = data
184
+ this._npmRc = npmrc
185
+ return this
186
+ }
187
+
188
+ private get _details(): string {
189
+ let string = `${G}${this.packageFile}${X}`
190
+ if (this.name || this.version) {
191
+ string += ` [${Y}`
192
+ if (this.name) string += `${this.name}`
193
+ if (this.name && this.version) string += ' '
194
+ if (this.version) string += `${this.version}`
195
+ string += `${X}]`
196
+ }
197
+ return string
198
+ }
199
+
200
+ private _bump(): void {
201
+ assert(this._originalVersion, 'Updater not initialized')
202
+
203
+ if (this._options.bump) {
204
+ this.version = semver.inc(this._originalVersion, this._options.bump) || this._originalVersion
205
+ }
72
206
  }
73
207
 
74
- /* ------------------------------------------------------------------------ *
75
- * Update the version for a single dependency *
76
- * ------------------------------------------------------------------------ */
77
- async function updateDependency(name: string, rangeString: string, npmrc: Record<string, any>): Promise<string> {
208
+ /** Update a single dependency, returning the highest matching version */
209
+ private async _updateDependency(name: string, rangeString: string): Promise<string> {
210
+ assert(this._npmRc, 'Updater not initialized')
211
+
212
+ /* Check if this is a workspace package */
213
+ if (this._workspaces.has(name)) {
214
+ this._debug(`Not processing workspace package ${Y}${name}${X}`)
215
+ return rangeString
216
+ }
217
+
218
+ /* Check that we have a proper range (^x... or ~x...) */
78
219
  const match = /^\s*([~^])\s*(\d+(\.\d+(\.\d+)?)?)\s*$/.exec(rangeString)
79
220
  if (! match) {
80
- $debug(`Not processing range ${G}${rangeString}${K} for ${Y}${name}${K}`)
221
+ this._debug(`Not processing range ${G}${rangeString}${X} for ${Y}${name}${X}`)
81
222
  return rangeString
82
223
  }
83
224
 
225
+ /* Extract specifier and version from the string range*/
84
226
  const [ , specifier = '', version = '' ] = match
85
227
 
86
- if (! strict) {
228
+ /* Extend range if not in strict mode */
229
+ if (! this._options.strict) {
87
230
  const r = rangeString
88
231
  rangeString = `>=${version}`
89
232
  if (specifier === '~') rangeString += ` <${semver.inc(version, 'major')}`
90
- $debug(`Extending version for ${Y}${name}${K} from ${G}${r}${K} to ${G}${rangeString}${K}`)
233
+ this._debug(`Extending version for ${Y}${name}${X} from ${G}${r}${X} to ${G}${rangeString}${X}`)
91
234
  }
92
235
 
236
+ /* Get the highest matching version and return it */
93
237
  const range = new semver.Range(rangeString)
94
- const versions = await getVersions(name, npmrc)
95
-
238
+ const versions = await this._cache.getVersions(name, this._npmRc)
96
239
  for (const v of versions) {
97
240
  if (range.test(v)) return `${specifier}${v}`
98
241
  }
242
+
243
+ /* No version found, return the original one cleaned up */
99
244
  return `${specifier}${version}`
100
245
  }
101
246
 
102
- /* ------------------------------------------------------------------------ *
103
- * Process all dependencies in a package file *
104
- * ------------------------------------------------------------------------ */
105
- async function processPackage(file: string): Promise<number> {
106
- process.stdout.write(`Processing ${G}${file}${K} `)
107
-
108
- const data = JSON.parse(await readFile(file, 'utf8'))
109
- if (data.name) {
110
- process.stdout.write(`[${Y}${data.name}`)
111
- if (data.version) process.stdout.write(` ${data.version}`)
112
- process.stdout.write(`${K}] `)
113
- }
114
- if (debug) process.stdout.write('\n')
115
-
116
- const npmrc = await readNpmRc(file)
247
+ /** Update a dependencies group, populating the "updated" version field */
248
+ private async _updateDependenciesGroup(type: DependencyType): Promise<DependencyChange []> {
249
+ assert(this._packageData, 'Updater not initialized')
250
+ const dependencies = this._packageData[type]
251
+ if (! dependencies) return []
252
+
253
+ /* Parallelize updates for this group */
254
+ const promises = Object.entries(dependencies)
255
+ .map(async ([ name, declared ]) => {
256
+ const updated = await this._updateDependency(name, declared)
257
+ if (! this._options.debug) process.stdout.write('.')
258
+ if (updated === declared) return
259
+
260
+ dependencies[name] = updated
261
+ return { name, declared, updated, type } satisfies DependencyChange
262
+ })
117
263
 
118
- const changes: Change[] = []
119
- let mainDependencyChanges = 0
264
+ /* Await all updates and return changes */
265
+ return (await Promise.all(promises))
266
+ .filter((change): change is DependencyChange => !! change)
267
+ }
120
268
 
121
- for (const type in data) {
122
- if (! type.match(/[dD]ependencies$/)) continue
123
- if (type.match(/bundled?Dependencies/)) continue
269
+ /** Update dependencies and return the version number of this package */
270
+ async update(): Promise<void> {
271
+ assert(this._packageData, 'Updater not initialized')
124
272
 
125
- const kind = type.length > 12 ? ` [${type.slice(0, -12)}]` : ''
273
+ /* Start by processing all workspaces first */
274
+ for (const child of this._children) await child.update()
126
275
 
127
- const dependencies: Record<string, string> = {}
128
- const promises = Object.keys(data[type] || {}).sort().map(async (name) => {
129
- const from: string = data[type][name]
130
- const to = await updateDependency(name, from, npmrc)
131
- if (! debug) process.stdout.write('.')
132
- if (from !== to) {
133
- changes.push({ name, from, to, kind })
134
- if (type === 'dependencies') mainDependencyChanges ++
135
- }
136
- dependencies[name] = to
137
- })
276
+ /* Some pretty printing of our package name and version */
277
+ process.stdout.write(`Processing ${this._details} `)
278
+ if (this._options.debug) process.stdout.write('\n')
138
279
 
139
- await Promise.all(promises)
280
+ /* Process the _main_ dependencies group first */
281
+ const changes = await this._updateDependenciesGroup('dependencies')
140
282
 
141
- if (Object.keys(dependencies).length) {
142
- data[type] = Object.entries(dependencies)
143
- .sort(([ a ], [ b ]) => a.localeCompare(b))
144
- .reduce((deps, [ name, version ]) => {
145
- deps[name] = version
146
- return deps
147
- }, {} as Record<string, string>)
148
- } else {
149
- delete data[type]
150
- }
283
+ /* Process all the other dependencies if we need to do so */
284
+ if (changes.length || (! this._options.quick)) {
285
+ changes.push(...await this._updateDependenciesGroup('devDependencies'))
286
+ changes.push(...await this._updateDependenciesGroup('optionalDependencies'))
287
+ changes.push(...await this._updateDependenciesGroup('peerDependencies'))
151
288
  }
152
289
 
153
- if (debug) process.stdout.write('Updated with')
154
-
155
- if (! changes.length) {
156
- console.log(` ${R}no changes${K}`)
157
- return 0
158
- }
290
+ /* Simply return if no changes were detected or mark this as changed */
291
+ if (this._options.debug) process.stdout.write('Updated with')
292
+ if (! changes.length) return void console.log(` ${R}no changes${X}`)
293
+ this._changed = true
159
294
 
160
- /* Really pretty print */
295
+ /* Really pretty print all our changed dependencies */
161
296
  changes.sort(({ name: a }, { name: b }) => a < b ? -1 : a > b ? 1 : 0)
162
- console.log(` ${R}${changes.length} changes${K}`)
163
- let lname = 0; let lfrom = 0; let lto = 0
164
- for (const { name, from, to } of changes) {
297
+ console.log(` ${R}${changes.length} changes${X}`)
298
+ let lname = 0
299
+ let ldeclared = 0
300
+ let lupdated = 0
301
+ for (const { name, declared, updated } of changes) {
165
302
  lname = lname > name.length ? lname : name.length
166
- lfrom = lfrom > from.length ? lfrom : from.length
167
- lto = lto > to.length ? lto : to.length
303
+ ldeclared = ldeclared > declared.length ? ldeclared : declared.length
304
+ lupdated = lupdated > updated.length ? lupdated : updated.length
168
305
  }
169
306
 
170
- for (const { name, from, to, kind } of changes) {
171
- console.log(` * ${Y}${name.padEnd(lname)}${K} : ${G}${from.padStart(lfrom)}${K} -> ${G}${to.padEnd(lto)} ${B}${kind}${K}`)
172
- }
173
-
174
- /* Ignore all changes if no main changes, and in "quick" mode */
175
- if (quick && (mainDependencyChanges === 0)) {
176
- console.log(`No changes to main dependencies, ${Y}ignoring ${changes.length} other changes${K}`)
177
- return 0
307
+ for (const { name, declared, updated, type } of changes) {
308
+ const kind =
309
+ type === 'devDependencies' ? 'dev' :
310
+ type === 'peerDependencies' ? 'peer' :
311
+ type === 'optionalDependencies' ? 'optional' :
312
+ 'main'
313
+ console.log([
314
+ ` * ${Y}${name.padEnd(lname)}${X}`,
315
+ ` : ${G}${declared.padStart(ldeclared)}${X}`,
316
+ ` -> ${G}${updated.padEnd(lupdated)} ${B}${kind}${X}`,
317
+ ].join(''))
178
318
  }
179
319
 
180
320
  /* Bump the package if we need to */
181
- if (bump) {
182
- const bumped = semver.inc(data.version, bump)
183
- console.log(` - Bumping version ${Y}${data.version}${K} -> ${G}${bumped}${K}`)
184
- data.version = bumped
321
+ this._bump()
322
+ }
323
+
324
+ align(version?: string): void {
325
+ if (this._workspaces.length < 2) {
326
+ return this._debug(`No workspaces found in ${this._details}`)
185
327
  }
186
328
 
187
- /* Write out the new package file */
188
- if (dryrun) {
189
- console.log(`Dry run, not writing ${G}${file}${K}`)
190
- return 0
191
- } else {
192
- await writeFile(file, JSON.stringify(data, null, 2) + '\n')
193
- return changes.length
329
+ if (! version) {
330
+ let aligned = '0.0.0'
331
+ for (const [ , version ] of this._workspaces) {
332
+ if (semver.gt(version, aligned)) aligned = version
333
+ }
334
+ version = aligned
194
335
  }
336
+
337
+ this.version = version
338
+ for (const child of this._children) child.version = version
339
+ console.log(`Workspaces versions aligned to ${Y}${version}${X}`)
195
340
  }
196
341
 
197
- /* ------------------------------------------------------------------------ *
198
- * Process a number of package files one by one *
199
- * ------------------------------------------------------------------------ */
200
- const files = await glob.glob(patterns)
201
- let changes = 0
342
+ /** Write out the new package file */
343
+ async write(): Promise<void> {
344
+ assert(this._packageData, 'Updater not initialized')
202
345
 
203
- let newline = false
204
- for (const file of files) {
205
- if (newline) console.log()
206
- const packageChanges = await processPackage(file)
207
- newline = !! packageChanges
208
- changes += packageChanges
209
- }
346
+ /* Sort all our dependencies */
347
+ for (const type of dependencyTypes) {
348
+ const dependencies = Object.entries(this._packageData[type] || {})
349
+ if (dependencies.length) {
350
+ this._packageData[type] = dependencies
351
+ .sort(([ nameA ], [ nameB ]) => nameA.localeCompare(nameB))
352
+ .reduce((deps, [ name, version ]) => {
353
+ deps[name] = version
354
+ return deps
355
+ }, {} as Record<string, string>)
356
+ } else {
357
+ delete this._packageData[type]
358
+ }
359
+ }
360
+ const json = JSON.stringify(this._packageData, null, 2)
361
+ this._debug(`${Y}>>>`, this.packageFile, `<<<${X}\n${json}`)
362
+ await writeFile(this.packageFile, json + '\n')
210
363
 
211
- return changes
364
+ for (const child of this._children) await child.write()
365
+ }
212
366
  }
@@ -0,0 +1,43 @@
1
+ import fetch from 'npm-registry-fetch'
2
+ import semver from 'semver'
3
+
4
+ import { X, Y, makeDebug } from './debug'
5
+
6
+ /** Cache of versions */
7
+ export class VersionsCache {
8
+ private _cache: Record<string, Promise<string[]>>
9
+ private _debug: (...args: any) => void
10
+
11
+ constructor(debug: boolean = false) {
12
+ this._debug = makeDebug(debug)
13
+ this._cache = {}
14
+ }
15
+
16
+ /** Return the available versions for a package, sorted */
17
+ getVersions(
18
+ name: string,
19
+ npmrc: Record<string, any>,
20
+ ): Promise<string[]> {
21
+ if (this._cache[name]) {
22
+ this._debug(`Returning cached versions for ${Y}${name}${X}`)
23
+ return this._cache[name]!
24
+ }
25
+
26
+ this._debug(`Retrieving versions for package ${Y}${name}${X}`)
27
+
28
+ const range = new semver.Range('>=0.0.0', { includePrerelease: false })
29
+
30
+ /* we cache the promise, so that multiple concurrent requests for the same
31
+ * package will generate only one http fetch request */
32
+ const promise = fetch.json(name, Object.assign({}, npmrc, { spec: name }))
33
+ .then((data: any) => {
34
+ return Object.entries(data.versions as Record<string, Record<string, any>>)
35
+ .filter(([ , info ]) => ! info.deprecated) // no deprecated
36
+ .map(([ version ]) => version) // extract key (version)
37
+ .filter((version) => range.test(version)) // range match
38
+ .sort(semver.rcompare)
39
+ })
40
+ this._cache[name] = promise
41
+ return promise
42
+ }
43
+ }