From b509254ee6ffa09b229f25e0e95c00a3f76b5353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 10 Oct 2026 17:44:35 +0200 Subject: [PATCH 1/3] fix: let prerelease builds follow their own channel A nightly build's update notice checks and recommends the nightly dist-tag. A prerelease Android helper takes its base release's versionCode instead of 1, so a nightly or -dev helper replaces an older release on the device. --- scripts/build-android-helper.sh | 4 +++- src/__tests__/update-check.test.ts | 30 ++++++++++++++++++++++++++++++ src/cli/update-check.ts | 14 ++++++++++---- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/scripts/build-android-helper.sh b/scripts/build-android-helper.sh index dd6d552e35..74134fdcf2 100755 --- a/scripts/build-android-helper.sh +++ b/scripts/build-android-helper.sh @@ -96,9 +96,11 @@ for BUILD_TOOL in aapt2 d8 zipalign apksigner; do fi done +# A prerelease shares its base release's code, so a nightly or -dev helper replaces an older +# release; an equal code reinstalls by checksum. VERSION_CODE="$( printf '%s\n' "$VERSION" | awk -F. ' - /^[0-9]+[.][0-9]+[.][0-9]+$/ { + /^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?$/ { print ($1 * 1000000) + ($2 * 1000) + $3 next } diff --git a/src/__tests__/update-check.test.ts b/src/__tests__/update-check.test.ts index 0c214f73b6..ef197c74ba 100644 --- a/src/__tests__/update-check.test.ts +++ b/src/__tests__/update-check.test.ts @@ -188,3 +188,33 @@ test('worker resets prompted state when it discovers a newer version', async () assert.equal(cache.latestVersion, '0.13.0'); assert.equal(cache.prompted, false); }); + +test('a nightly build checks and recommends the nightly dist-tag', async () => { + const stateDir = makeTempStateDir(); + cleanupPaths.push(stateDir); + const cachePath = path.join(stateDir, 'update-check.json'); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ version: '0.12.0-nightly.20261011.8' }), + }); + vi.stubGlobal('fetch', fetchMock); + + await runUpdateCheckWorker({ cachePath, currentVersion: '0.12.0-nightly.20261010.7' }); + + assert.equal(fetchMock.mock.calls[0]?.[0], 'https://registry.npmjs.org/agent-device/nightly'); + let stderr = ''; + vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write); + maybeRunUpgradeNotifier({ + command: 'devices', + currentVersion: '0.12.0-nightly.20261010.7', + stateDir, + flags: {}, + }); + assert.match( + stderr, + /-> 0\.12\.0-nightly\.20261011\.8\. Run `npm install -g agent-device@nightly`/, + ); +}); diff --git a/src/cli/update-check.ts b/src/cli/update-check.ts index 6a1e0a7039..1de41b3b0e 100644 --- a/src/cli/update-check.ts +++ b/src/cli/update-check.ts @@ -42,7 +42,7 @@ export function maybeRunUpgradeNotifier(options: UpgradeNotifierOptions): void { if (shouldShowUpgradeNotice(cache, options.currentVersion)) { process.stderr.write( `Update available: ${PACKAGE_NAME} ${options.currentVersion} -> ${cache.latestVersion}. ` + - `Run \`npm install -g ${PACKAGE_NAME}@latest\` to upgrade the CLI.\n`, + `Run \`npm install -g ${PACKAGE_NAME}@${distTagFor(options.currentVersion)}\` to upgrade the CLI.\n`, ); writeUpdateCheckCache(cachePath, { ...cache, @@ -60,7 +60,8 @@ export async function runUpdateCheckWorker(options: UpdateCheckWorkerOptions): P const cache = readUpdateCheckCache(options.cachePath); try { - const latestVersion = (await fetchLatestPackageVersion()) ?? undefined; + const latestVersion = + (await fetchLatestPackageVersion(distTagFor(options.currentVersion))) ?? undefined; if (!latestVersion || compareVersions(latestVersion, options.currentVersion) <= 0) { writeUpdateCheckCache(options.cachePath, { checkedAt: new Date(now).toISOString() }); return; @@ -150,8 +151,13 @@ function spawnBackgroundUpdateCheck(cachePath: string, currentVersion: string): ]); } -async function fetchLatestPackageVersion(): Promise { - const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { +/** A nightly build follows the `nightly` dist-tag; every other build follows `latest`. */ +function distTagFor(version: string): 'latest' | 'nightly' { + return version.includes('-nightly.') ? 'nightly' : 'latest'; +} + +async function fetchLatestPackageVersion(distTag: string): Promise { + const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/${distTag}`, { signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS), headers: { accept: 'application/json' }, }); From d6ca2badb8fb68dfcdfc59354c10572b83cfaecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 08:43:00 +0200 Subject: [PATCH 2/3] fix(cli): keep the update notice on the channel its cache was read from The update-check cache records the dist-tag it fetched. A cached version from the other channel is not shown, and switching channels starts a fresh check. --- src/__tests__/update-check.test.ts | 21 +++++++++++++++++++++ src/cli/update-check.ts | 24 +++++++++++++++++++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/__tests__/update-check.test.ts b/src/__tests__/update-check.test.ts index ef197c74ba..fef7266b58 100644 --- a/src/__tests__/update-check.test.ts +++ b/src/__tests__/update-check.test.ts @@ -218,3 +218,24 @@ test('a nightly build checks and recommends the nightly dist-tag', async () => { /-> 0\.12\.0-nightly\.20261011\.8\. Run `npm install -g agent-device@nightly`/, ); }); + +test('a cached version from the other channel is neither shown nor kept past a channel switch', () => { + vi.mocked(runCmdDetached).mockClear(); + const stateDir = makeTempStateDir(); + cleanupPaths.push(stateDir); + writeCache(stateDir, { + latestVersion: '0.12.0-nightly.20261011.8', + distTag: 'nightly', + checkedAt: '2026-03-30T10:00:00.000Z', + }); + let stderr = ''; + vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write); + + maybeRunUpgradeNotifier({ command: 'devices', currentVersion: '0.11.3', stateDir, flags: {} }); + + assert.equal(stderr, ''); + assert.equal(vi.mocked(runCmdDetached).mock.calls.length, 1, 'the switch starts a fresh check'); +}); diff --git a/src/cli/update-check.ts b/src/cli/update-check.ts index 1de41b3b0e..25271fe3b0 100644 --- a/src/cli/update-check.ts +++ b/src/cli/update-check.ts @@ -12,6 +12,8 @@ const UPDATE_CHECK_CACHE_FILE = 'update-check.json'; type UpdateCheckCache = { checkedAt?: string; + /** The dist-tag `latestVersion` was read from; a cache from before channels means `latest`. */ + distTag?: string; latestVersion?: string; prompted?: boolean; }; @@ -50,7 +52,7 @@ export function maybeRunUpgradeNotifier(options: UpgradeNotifierOptions): void { }); } - if (shouldStartBackgroundCheck(cache, now)) { + if (shouldStartBackgroundCheck(cache, options.currentVersion, now)) { spawnBackgroundUpdateCheck(cachePath, options.currentVersion); } } @@ -59,16 +61,17 @@ export async function runUpdateCheckWorker(options: UpdateCheckWorkerOptions): P const now = Date.now(); const cache = readUpdateCheckCache(options.cachePath); + const distTag = distTagFor(options.currentVersion); try { - const latestVersion = - (await fetchLatestPackageVersion(distTagFor(options.currentVersion))) ?? undefined; + const latestVersion = (await fetchLatestPackageVersion(distTag)) ?? undefined; if (!latestVersion || compareVersions(latestVersion, options.currentVersion) <= 0) { - writeUpdateCheckCache(options.cachePath, { checkedAt: new Date(now).toISOString() }); + writeUpdateCheckCache(options.cachePath, { checkedAt: new Date(now).toISOString(), distTag }); return; } writeUpdateCheckCache(options.cachePath, { checkedAt: new Date(now).toISOString(), + distTag, latestVersion, prompted: cache.latestVersion === latestVersion ? cache.prompted === true : false, }); @@ -92,11 +95,17 @@ function shouldEnableUpgradeNotifier(options: UpgradeNotifierOptions): boolean { function shouldShowUpgradeNotice(cache: UpdateCheckCache, currentVersion: string): boolean { if (!cache.latestVersion) return false; + if (cachedDistTag(cache) !== distTagFor(currentVersion)) return false; if (compareVersions(cache.latestVersion, currentVersion) <= 0) return false; return cache.prompted !== true; } -function shouldStartBackgroundCheck(cache: UpdateCheckCache, now: number): boolean { +function shouldStartBackgroundCheck( + cache: UpdateCheckCache, + currentVersion: string, + now: number, +): boolean { + if (cachedDistTag(cache) !== distTagFor(currentVersion)) return true; const checkedAt = parseTimestamp(cache.checkedAt); return checkedAt === undefined || now - checkedAt >= UPDATE_CHECK_INTERVAL_MS; } @@ -106,6 +115,7 @@ function readUpdateCheckCache(cachePath: string): UpdateCheckCache { const raw = JSON.parse(fs.readFileSync(cachePath, 'utf8')) as UpdateCheckCache; return { checkedAt: typeof raw.checkedAt === 'string' ? raw.checkedAt : undefined, + distTag: typeof raw.distTag === 'string' ? raw.distTag : undefined, latestVersion: typeof raw.latestVersion === 'string' ? raw.latestVersion : undefined, prompted: raw.prompted === true, }; @@ -151,6 +161,10 @@ function spawnBackgroundUpdateCheck(cachePath: string, currentVersion: string): ]); } +function cachedDistTag(cache: UpdateCheckCache): string { + return cache.distTag ?? 'latest'; +} + /** A nightly build follows the `nightly` dist-tag; every other build follows `latest`. */ function distTagFor(version: string): 'latest' | 'nightly' { return version.includes('-nightly.') ? 'nightly' : 'latest'; From cdf4e654b7f067b296f96ca6032b9633a56d0732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 08:57:13 +0200 Subject: [PATCH 3/3] fix(cli): record the channel even when an update check fails A failed check after a channel switch kept the old channel's dist-tag, so every command started another check. It now records the current channel and drops a version cached from the other one. --- src/__tests__/update-check.test.ts | 19 +++++++++++++++++++ src/cli/update-check.ts | 6 +++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/__tests__/update-check.test.ts b/src/__tests__/update-check.test.ts index fef7266b58..209f9efd03 100644 --- a/src/__tests__/update-check.test.ts +++ b/src/__tests__/update-check.test.ts @@ -239,3 +239,22 @@ test('a cached version from the other channel is neither shown nor kept past a c assert.equal(stderr, ''); assert.equal(vi.mocked(runCmdDetached).mock.calls.length, 1, 'the switch starts a fresh check'); }); + +test('a failed check after a channel switch records the new channel and drops the old version', async () => { + const stateDir = makeTempStateDir(); + cleanupPaths.push(stateDir); + const cachePath = path.join(stateDir, 'update-check.json'); + writeCache(stateDir, { + latestVersion: '0.12.0-nightly.20261011.8', + distTag: 'nightly', + checkedAt: '2026-03-30T10:00:00.000Z', + }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))); + + await runUpdateCheckWorker({ cachePath, currentVersion: '0.11.3' }); + + const cache = readCache(stateDir); + assert.equal(cache.distTag, 'latest'); + assert.equal(cache.latestVersion, undefined); + assert.equal(cache.checkedAt, '2026-03-31T10:00:00.000Z'); +}); diff --git a/src/cli/update-check.ts b/src/cli/update-check.ts index 25271fe3b0..916d2f84f4 100644 --- a/src/cli/update-check.ts +++ b/src/cli/update-check.ts @@ -76,9 +76,13 @@ export async function runUpdateCheckWorker(options: UpdateCheckWorkerOptions): P prompted: cache.latestVersion === latestVersion ? cache.prompted === true : false, }); } catch { + // A failed check still records this channel, so a channel switch waits out the interval + // instead of retrying on every command, and never keeps the other channel's version. + const kept = cachedDistTag(cache) === distTag ? cache : {}; writeUpdateCheckCache(options.cachePath, { - ...cache, + ...kept, checkedAt: new Date(now).toISOString(), + distTag, }); } }