Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion scripts/build-android-helper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,73 @@ 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`/,
);
});

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');
});

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');
});
40 changes: 32 additions & 8 deletions src/cli/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -42,15 +44,15 @@ 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`,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
);
writeUpdateCheckCache(cachePath, {
...cache,
prompted: true,
});
}

if (shouldStartBackgroundCheck(cache, now)) {
if (shouldStartBackgroundCheck(cache, options.currentVersion, now)) {
spawnBackgroundUpdateCheck(cachePath, options.currentVersion);
}
}
Expand All @@ -59,22 +61,28 @@ 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()) ?? 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,
});
} 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,
});
}
}
Expand All @@ -91,11 +99,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;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const checkedAt = parseTimestamp(cache.checkedAt);
return checkedAt === undefined || now - checkedAt >= UPDATE_CHECK_INTERVAL_MS;
}
Expand All @@ -105,6 +119,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,
};
Expand Down Expand Up @@ -150,8 +165,17 @@ function spawnBackgroundUpdateCheck(cachePath: string, currentVersion: string):
]);
}

async function fetchLatestPackageVersion(): Promise<string | undefined> {
const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
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';
}

async function fetchLatestPackageVersion(distTag: string): Promise<string | undefined> {
const response = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/${distTag}`, {
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
headers: { accept: 'application/json' },
});
Expand Down
Loading