Skip to content

Commit 9cb2b6b

Browse files
committed
Publish v3.2.2
1 parent 1bf961c commit 9cb2b6b

5 files changed

Lines changed: 113 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [3.2.2] — 2026-05-20
11+
12+
### Fixed
13+
14+
- The foreground CLI update notice and daemon self-update check now compare
15+
versions numerically, so an unpublished local patch release no longer treats
16+
an older npm version as newer.
17+
1018
## [3.2.1] — 2026-05-20
1119

1220
### Fixed

bin/cli.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,13 @@ async function loadSubcommand(name) {
129129
*/
130130
async function checkForUpdates() {
131131
try {
132-
const [{ readPackageVersion }, { fetchLatestVersion }] = await Promise.all([
132+
const [{ readPackageVersion }, { fetchLatestVersion, isNewerVersion }] = await Promise.all([
133133
import('../src/cli/common.js'),
134134
import('../src/update.js'),
135135
])
136136
const currentVersion = readPackageVersion()
137137
const latest = await fetchLatestVersion()
138-
if (latest && latest !== currentVersion) {
138+
if (latest && isNewerVersion(latest, currentVersion)) {
139139
process.stderr.write(
140140
`\x1b[33mA newer version of collectivus is available: ${latest} (current: ${currentVersion})\x1b[0m\n` +
141141
'\x1b[33mRun \'npm install -g collectivus\' to update\x1b[0m\n'

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "collectivus",
3-
"version": "3.2.1",
3+
"version": "3.2.2",
44
"description": "OTLP collector and pass-through LLM proxy",
55
"author": "Hyperparam",
66
"homepage": "https://hyperparam.app",

src/update.js

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,30 @@ export async function fetchLatestVersion(options = {}) {
3838
}
3939
}
4040

41+
/**
42+
* Return whether `candidate` is newer than `current` using the package's
43+
* semver-shaped versions. Invalid versions are treated as not newer so a
44+
* malformed registry response cannot trigger a bogus update notice.
45+
*
46+
* @param {string} candidate
47+
* @param {string} current
48+
* @returns {boolean}
49+
*/
50+
export function isNewerVersion(candidate, current) {
51+
const next = parseVersion(candidate)
52+
const base = parseVersion(current)
53+
if (!next || !base) return false
54+
for (const key of ['major', 'minor', 'patch']) {
55+
const k = /** @type {'major' | 'minor' | 'patch'} */ (key)
56+
if (next[k] > base[k]) return true
57+
if (next[k] < base[k]) return false
58+
}
59+
if (next.prerelease === base.prerelease) return false
60+
if (!next.prerelease) return Boolean(base.prerelease)
61+
if (!base.prerelease) return false
62+
return comparePrerelease(next.prerelease, base.prerelease) > 0
63+
}
64+
4165
/**
4266
* Whether the running script lives somewhere `npm install -g` can replace.
4367
*
@@ -116,7 +140,7 @@ export async function selfUpdate(options = {}) {
116140

117141
const currentVersion = readVersion()
118142
const latest = await fetchLatest()
119-
if (!latest || latest === currentVersion) return
143+
if (!latest || !isNewerVersion(latest, currentVersion)) return
120144

121145
log.write(`[collectivus] update available: ${currentVersion} -> ${latest}; running npm install -g collectivus@${latest}\n`)
122146
const installed = await install(latest)
@@ -163,3 +187,45 @@ function defaultRun(command, args, opts) {
163187
child.once('exit', (code) => resolve(code === null ? -1 : code))
164188
})
165189
}
190+
191+
/**
192+
* @param {string} value
193+
* @returns {{ major: number, minor: number, patch: number, prerelease: string } | undefined}
194+
*/
195+
function parseVersion(value) {
196+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(value)
197+
if (!match) return undefined
198+
return {
199+
major: Number(match[1]),
200+
minor: Number(match[2]),
201+
patch: Number(match[3]),
202+
prerelease: match[4] ?? '',
203+
}
204+
}
205+
206+
/**
207+
* @param {string} a
208+
* @param {string} b
209+
* @returns {number}
210+
*/
211+
function comparePrerelease(a, b) {
212+
const aa = a.split('.')
213+
const bb = b.split('.')
214+
const len = Math.max(aa.length, bb.length)
215+
for (let i = 0; i < len; i++) {
216+
const av = aa[i]
217+
const bv = bb[i]
218+
if (av === undefined) return -1
219+
if (bv === undefined) return 1
220+
const an = /^\d+$/.test(av) ? Number(av) : undefined
221+
const bn = /^\d+$/.test(bv) ? Number(bv) : undefined
222+
if (an !== undefined && bn !== undefined) {
223+
if (an !== bn) return an - bn
224+
continue
225+
}
226+
if (an !== undefined) return -1
227+
if (bn !== undefined) return 1
228+
if (av !== bv) return av < bv ? -1 : 1
229+
}
230+
return 0
231+
}

test/update.test.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest'
2-
import { canSelfUpdate, fetchLatestVersion, isSupervised, runNpmInstall, selfUpdate } from '../src/update.js'
2+
import { canSelfUpdate, fetchLatestVersion, isNewerVersion, isSupervised, runNpmInstall, selfUpdate } from '../src/update.js'
33

44
/**
55
* Minimal stdout/stderr collector.
@@ -73,6 +73,25 @@ describe('fetchLatestVersion', () => {
7373
})
7474
})
7575

76+
describe('isNewerVersion', () => {
77+
it('compares semver components numerically', () => {
78+
expect(isNewerVersion('3.2.0', '3.2.1')).toBe(false)
79+
expect(isNewerVersion('3.10.0', '3.2.9')).toBe(true)
80+
expect(isNewerVersion('4.0.0', '3.99.99')).toBe(true)
81+
})
82+
83+
it('handles prerelease precedence', () => {
84+
expect(isNewerVersion('3.2.1', '3.2.1-beta.1')).toBe(true)
85+
expect(isNewerVersion('3.2.1-beta.2', '3.2.1-beta.1')).toBe(true)
86+
expect(isNewerVersion('3.2.1-beta.1', '3.2.1')).toBe(false)
87+
})
88+
89+
it('treats invalid versions as not newer', () => {
90+
expect(isNewerVersion('latest', '3.2.1')).toBe(false)
91+
expect(isNewerVersion('3.2.2', 'current')).toBe(false)
92+
})
93+
})
94+
7695
describe('runNpmInstall', () => {
7796
it('returns true on exit code 0', async () => {
7897
/** @type {string[]} */
@@ -140,6 +159,21 @@ describe('selfUpdate', () => {
140159
expect(installCalls).toBe(0)
141160
})
142161

162+
it('does not install when registry returns an older version', async () => {
163+
let installCalls = 0
164+
const log = memo()
165+
const result = await selfUpdate({
166+
binPath: installedBinPath,
167+
readVersion: () => '3.2.1',
168+
fetchLatest: () => Promise.resolve('3.2.0'),
169+
install: () => { installCalls++; return Promise.resolve(true) },
170+
log,
171+
})
172+
expect(result).toBeUndefined()
173+
expect(installCalls).toBe(0)
174+
expect(log.value()).toBe('')
175+
})
176+
143177
it('installs and returns the new version when newer', async () => {
144178
/** @type {string[]} */
145179
const installedVersions = []

0 commit comments

Comments
 (0)