From bce84843833018fc3aab038e7f7edf0abdbe9ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20Can=20G=C3=BCm=C3=BC=C5=9F?= Date: Fri, 3 Jul 2026 09:14:28 +0300 Subject: [PATCH] feat(cli): add squad update-check --json for tooling/CI Adds a squad update-check command that reads the existing update-check.json cache (already maintained by self-update.ts) and reports current/latest version, release channel, and update availability, instead of requiring every consumer (editor extensions, coordinator instructions, CI scripts) to replicate OS-specific cache path resolution and TTL-freshness checks themselves. - --json emits structured output matching the schema proposed in #1170 - --refresh bypasses the cache and re-fetches from the npm registry - Exit codes: 0 (up to date / no cache yet), 1 (update available), 2 (transport failure during --refresh) - Honors SQUAD_NO_UPDATE_CHECK=1 (no network call, silent exit 0) Release channel is derived from the running version's prerelease tag (stable/preview/insider) via the existing ReleaseChannel type in upgrade.ts. cacheAge is formatted as an ISO 8601 duration (e.g. "PT2H15M"). Exported getCachePath/fetchLatestVersion/writeCache from self-update.ts for reuse instead of duplicating cache I/O. Closes #1170 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/feat-1170-update-check-json.md | 12 + README.md | 1 + .../src/content/docs/features/self-upgrade.md | 39 +++ packages/squad-cli/package.json | 4 + packages/squad-cli/src/cli-entry.ts | 8 + .../src/cli/commands/update-check.ts | 166 +++++++++++++ .../squad-cli/src/cli/core/command-help.ts | 12 + packages/squad-cli/src/cli/self-update.ts | 9 +- test/cli/command-help.test.ts | 1 + test/cli/update-check.test.ts | 229 ++++++++++++++++++ 10 files changed, 477 insertions(+), 4 deletions(-) create mode 100644 .changeset/feat-1170-update-check-json.md create mode 100644 packages/squad-cli/src/cli/commands/update-check.ts create mode 100644 test/cli/update-check.test.ts diff --git a/.changeset/feat-1170-update-check-json.md b/.changeset/feat-1170-update-check-json.md new file mode 100644 index 000000000..2608be7ee --- /dev/null +++ b/.changeset/feat-1170-update-check-json.md @@ -0,0 +1,12 @@ +--- +"@bradygaster/squad-cli": minor +--- + +Add `squad update-check` — reads the existing self-update cache (`update-check.json`) and reports current/latest version, release channel, and update availability, without replicating OS-specific cache path resolution or TTL-freshness checks in every consumer. + +- `--json` emits structured output for editor extensions, CI scripts, and coordinator instructions. +- `--refresh` bypasses the cache and re-fetches from the npm registry. +- Exit codes: `0` (up to date / no cache yet), `1` (update available), `2` (transport failure during `--refresh`). +- Honors `SQUAD_NO_UPDATE_CHECK=1` — no network call, exits `0` silently. + +Closes #1170. diff --git a/README.md b/README.md index 339a7a44a..e25d31bb7 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ Say **"squad commands"** in chat to see a categorized menu of common operations | `squad init` | **Init** — scaffold Squad in the current directory (idempotent — safe to run multiple times); alias: `cast`; use `--global` to init in personal squad directory, `--mode remote ` for dual-root mode | | `squad upgrade` | Update Squad-owned files to latest; never touches your team state; use `--global` to upgrade personal squad, `--migrate-directory` to rename `.ai-team/` → `.squad/` | | `squad upgrade --self` | Update the Squad CLI package itself; add `--insider` for dev-channel prerelease builds | +| `squad update-check` | Report cached CLI update status for tooling/CI; use `--json` for structured output, `--refresh` to bypass the cache | | `squad status` | Show which squad is active and why | | `squad triage` | **Watch mode** — poll for issues and auto-triage to team (aliases: `watch`, `loop`); use `--interval ` to set polling frequency (default: 10); with `--execute` dispatch Copilot agents; use `--agent-cmd`, `--copilot-flags`, `--auth-user` to customize agent execution; `--health` shows watch status; `--log-file` for diagnostics | | `squad copilot` | Add/remove the Copilot coding agent (@copilot); use `--off` to remove, `--auto-assign` to enable auto-assignment | diff --git a/docs/src/content/docs/features/self-upgrade.md b/docs/src/content/docs/features/self-upgrade.md index 20fd7f86a..fb97f56de 100644 --- a/docs/src/content/docs/features/self-upgrade.md +++ b/docs/src/content/docs/features/self-upgrade.md @@ -181,6 +181,45 @@ Package Current Wanted Latest Location --- +## Checking Update Status Programmatically + +```bash +squad update-check --json +``` + +Reads the same cache the background startup check maintains and prints it as structured JSON, without making a network call: + +```json +{ + "current": "0.9.6-insider.2", + "channel": "insider", + "latest": "0.9.7-insider.1", + "updateAvailable": true, + "cacheAge": "PT2H15M", + "checkedAt": "2026-05-26T12:00:00.000Z" +} +``` + +This gives editor extensions, coordinator instructions, and CI scripts a stable interface to query update status without replicating the OS-specific cache path or TTL-freshness logic themselves. + +**Flags:** +- `--json` — structured output (shown above) +- `--refresh` — bypass the cache and re-fetch from the npm registry now + +**Exit codes:** `0` (up to date, or no cache yet), `1` (update available), `2` (transport failure during `--refresh`) + +**Default (non-JSON) output:** + +``` +Current: 0.9.6-insider.2 (insider channel) +Latest: 0.9.7-insider.1 +Update available. Run `squad upgrade --self` to install. +``` + +Honors `SQUAD_NO_UPDATE_CHECK=1` — exits `0` with no output (or `{}` with `--json`) and never calls the network. + +--- + ## Release Channels | Channel | Tag | Description | diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index 30dae3c60..735655b4c 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -109,6 +109,10 @@ "types": "./dist/cli/commands/doctor.d.ts", "import": "./dist/cli/commands/doctor.js" }, + "./commands/update-check": { + "types": "./dist/cli/commands/update-check.d.ts", + "import": "./dist/cli/commands/update-check.js" + }, "./commands/aspire": { "types": "./dist/cli/commands/aspire.d.ts", "import": "./dist/cli/commands/aspire.js" diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index 71ce72b22..b92d95dd2 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -168,6 +168,8 @@ async function main(): Promise { console.log(` Flags: --global (upgrade personal squad)`); console.log(` --migrate-directory (rename .ai-team/ → .squad/)`); console.log(` --state-backend (migrate to orphan|two-layer)`); + console.log(` ${BOLD}update-check${RESET} Report cached CLI update status (for tooling/CI)`); + console.log(` Flags: --json (structured output), --refresh (bypass cache)`); console.log(` ${BOLD}migrate${RESET} Convert between markdown and SDK-First squad formats`); console.log(` Flags: --to sdk|markdown, --from ai-team, --dry-run`); console.log(` ${BOLD}sync${RESET} Sync squad-state branch(es) with remote (push/pull/both)`); @@ -501,6 +503,12 @@ async function main(): Promise { return; } + if (cmd === 'update-check') { + const { runUpdateCheckCommand } = await import('./cli/commands/update-check.js'); + const exitCode = await runUpdateCheckCommand(args.slice(1)); + process.exit(exitCode); + } + if (cmd === 'memory') { const { runMemoryCommand } = await import('./cli/commands/memory.js'); await runMemoryCommand(getSquadStartDir(), args.slice(1)); diff --git a/packages/squad-cli/src/cli/commands/update-check.ts b/packages/squad-cli/src/cli/commands/update-check.ts new file mode 100644 index 000000000..154b2aca5 --- /dev/null +++ b/packages/squad-cli/src/cli/commands/update-check.ts @@ -0,0 +1,166 @@ +/** + * `squad update-check` — expose cached CLI update status for tooling. + * + * Reads the update-check cache maintained by self-update.ts and reports it + * as human-readable text or structured JSON (--json). This gives editor + * extensions, coordinator instructions, and CI scripts a stable interface + * to query update status without replicating OS-specific cache path + * resolution or TTL freshness checks themselves. + * + * @module cli/commands/update-check + */ + +import { FSStorageProvider } from '@bradygaster/squad-sdk'; +import { type CacheData, getCachePath, fetchLatestVersion, writeCache } from '../self-update.js'; +import { parseVersion, isNewer, type ReleaseChannel } from '../upgrade.js'; +import { getPackageVersion } from '../core/version.js'; + +const storage = new FSStorageProvider(); + +export interface UpdateCheckResult { + current: string; + channel: ReleaseChannel; + latest: string | null; + updateAvailable: boolean; + cacheAge: string | null; + checkedAt: string | null; + error?: string; +} + +/** Detect release channel from a version's prerelease tag (e.g. "insider.2" → "insider"). */ +export function detectChannel(version: string): ReleaseChannel { + const { prerelease } = parseVersion(version); + if (prerelease.startsWith('insider')) return 'insider'; + if (prerelease.startsWith('preview')) return 'preview'; + return 'stable'; +} + +/** Format a millisecond duration as an ISO 8601 duration string (e.g. "PT2H15M"). */ +export function formatCacheAge(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + let result = 'PT'; + if (hours > 0) result += `${hours}H`; + if (minutes > 0) result += `${minutes}M`; + if (seconds > 0 || result === 'PT') result += `${seconds}S`; + return result; +} + +/** Read the raw update-check cache, regardless of TTL freshness. */ +function readRawCache(): CacheData | null { + try { + const raw = storage.readSync(getCachePath()); + if (!raw) return null; + return JSON.parse(raw) as CacheData; + } catch { + return null; + } +} + +/** + * Build the update-check result for the given current version. + * + * Without `refresh`, only reads the existing cache — never makes a network + * call. With `refresh`, always re-fetches from the npm registry and updates + * the cache. + */ +export async function runUpdateCheck( + currentVersion: string, + options: { refresh?: boolean } = {}, +): Promise { + const channel = detectChannel(currentVersion); + + if (options.refresh) { + const latest = await fetchLatestVersion(); + if (!latest) { + return { + current: currentVersion, + channel, + latest: null, + updateAvailable: false, + cacheAge: null, + checkedAt: null, + error: 'Failed to reach the npm registry', + }; + } + const checkedAt = Date.now(); + writeCache({ latestVersion: latest, checkedAt }); + return { + current: currentVersion, + channel, + latest, + updateAvailable: isNewer(currentVersion, latest), + cacheAge: formatCacheAge(0), + checkedAt: new Date(checkedAt).toISOString(), + }; + } + + const cached = readRawCache(); + if (!cached) { + return { + current: currentVersion, + channel, + latest: null, + updateAvailable: false, + cacheAge: null, + checkedAt: null, + }; + } + + return { + current: currentVersion, + channel, + latest: cached.latestVersion, + updateAvailable: isNewer(currentVersion, cached.latestVersion), + cacheAge: formatCacheAge(Date.now() - cached.checkedAt), + checkedAt: new Date(cached.checkedAt).toISOString(), + }; +} + +/** Print the result in human-readable form. */ +function printHuman(result: UpdateCheckResult): void { + if (result.error) { + console.error(`❌ ${result.error}`); + return; + } + console.log(`Current: ${result.current} (${result.channel} channel)`); + if (!result.latest) { + console.log('No cached update status yet. Run `squad update-check --refresh` to check now.'); + return; + } + console.log(`Latest: ${result.latest}`); + if (result.updateAvailable) { + console.log('Update available. Run `squad upgrade --self` to install.'); + } else { + console.log('Up to date.'); + } +} + +/** + * CLI entry point for `squad update-check`. + * @returns the process exit code: 0 (up to date / no cache yet), + * 1 (update available), 2 (transport failure during --refresh). + */ +export async function runUpdateCheckCommand(args: string[]): Promise { + const json = args.includes('--json'); + + // Respect the same opt-out as the background startup check. + if (process.env.SQUAD_NO_UPDATE_CHECK === '1') { + if (json) console.log('{}'); + return 0; + } + + const refresh = args.includes('--refresh'); + const result = await runUpdateCheck(getPackageVersion(), { refresh }); + + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + printHuman(result); + } + + if (result.error) return 2; + return result.updateAvailable ? 1 : 0; +} diff --git a/packages/squad-cli/src/cli/core/command-help.ts b/packages/squad-cli/src/cli/core/command-help.ts index e3daccdd0..90ff42619 100644 --- a/packages/squad-cli/src/cli/core/command-help.ts +++ b/packages/squad-cli/src/cli/core/command-help.ts @@ -63,6 +63,18 @@ const COMMAND_HELP: Record = { console.log(` ${BOLD}--force${RESET} Overwrite files without prompting\n`); }, + 'update-check': (version) => { + header('update-check', version, 'Report cached CLI update status'); + console.log(`Usage: squad update-check [options]\n`); + console.log(`Reads the update-check cache maintained in the background by the CLI`); + console.log(`and reports it without making a network call, unless --refresh is used.\n`); + console.log(`Options:`); + console.log(` ${BOLD}--json${RESET} Emit structured JSON instead of text`); + console.log(` ${BOLD}--refresh${RESET} Bypass the cache and re-fetch from npm\n`); + console.log(`Exit codes: 0 (up to date / no cache yet), 1 (update available),`); + console.log(` 2 (transport failure during --refresh)\n`); + }, + migrate: (version) => { header('migrate', version, 'Convert between markdown and SDK-First squad formats'); console.log(`Usage: squad migrate --to sdk|markdown [options]\n`); diff --git a/packages/squad-cli/src/cli/self-update.ts b/packages/squad-cli/src/cli/self-update.ts index 024a65723..3aded55b3 100644 --- a/packages/squad-cli/src/cli/self-update.ts +++ b/packages/squad-cli/src/cli/self-update.ts @@ -23,7 +23,7 @@ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours const FETCH_TIMEOUT_MS = 3000; // 3 seconds -interface CacheData { +export interface CacheData { latestVersion: string; checkedAt: number; } @@ -37,7 +37,8 @@ function getCacheDir(): string { return path.join(base, 'squad-cli'); } -function getCachePath(): string { +/** Path to the update-check cache file (shared with `squad update-check`). */ +export function getCachePath(): string { return path.join(getCacheDir(), 'update-check.json'); } @@ -57,7 +58,7 @@ function readCache(): CacheData | null { } /** Write version check result to cache. */ -function writeCache(data: CacheData): void { +export function writeCache(data: CacheData): void { try { const dir = getCacheDir(); storage.mkdirSync(dir, { recursive: true }); @@ -68,7 +69,7 @@ function writeCache(data: CacheData): void { } /** Fetch latest version from npm registry with timeout. */ -async function fetchLatestVersion(): Promise { +export async function fetchLatestVersion(): Promise { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); diff --git a/test/cli/command-help.test.ts b/test/cli/command-help.test.ts index b6490ad6a..417643f7c 100644 --- a/test/cli/command-help.test.ts +++ b/test/cli/command-help.test.ts @@ -133,6 +133,7 @@ describe('printCommandHelp', () => { 'status', 'subsquads', 'triage', + 'update-check', 'upgrade', 'upstream', 'watch', diff --git a/test/cli/update-check.test.ts b/test/cli/update-check.test.ts new file mode 100644 index 000000000..a46fc89e7 --- /dev/null +++ b/test/cli/update-check.test.ts @@ -0,0 +1,229 @@ +/** + * `squad update-check` — cached update status for tooling (#1170) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../../packages/squad-cli/src/cli/self-update.js', () => ({ + getCachePath: vi.fn(), + fetchLatestVersion: vi.fn(), + writeCache: vi.fn(), +})); + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as selfUpdate from '../../packages/squad-cli/src/cli/self-update.js'; +import { + detectChannel, + formatCacheAge, + runUpdateCheck, + runUpdateCheckCommand, +} from '../../packages/squad-cli/src/cli/commands/update-check.js'; + +describe('detectChannel()', () => { + it('returns stable for a clean release version', () => { + expect(detectChannel('0.11.0')).toBe('stable'); + }); + + it('returns insider for an insider prerelease', () => { + expect(detectChannel('0.11.0-insider.3')).toBe('insider'); + }); + + it('returns preview for a preview prerelease', () => { + expect(detectChannel('0.11.0-preview.1')).toBe('preview'); + }); + + it('returns preview for a bare preview tag with no iteration number', () => { + expect(detectChannel('0.11.0-preview')).toBe('preview'); + }); + + it('falls back to stable for an unrecognized prerelease tag', () => { + expect(detectChannel('0.11.0-build.1')).toBe('stable'); + }); +}); + +describe('formatCacheAge()', () => { + it('formats a zero duration', () => { + expect(formatCacheAge(0)).toBe('PT0S'); + }); + + it('formats seconds only', () => { + expect(formatCacheAge(45_000)).toBe('PT45S'); + }); + + it('formats minutes only', () => { + expect(formatCacheAge(15 * 60 * 1000)).toBe('PT15M'); + }); + + it('formats hours and minutes together', () => { + expect(formatCacheAge(2 * 3600 * 1000 + 15 * 60 * 1000)).toBe('PT2H15M'); + }); + + it('formats an exact hour with no trailing minutes/seconds', () => { + expect(formatCacheAge(3600 * 1000)).toBe('PT1H'); + }); +}); + +describe('runUpdateCheck()', () => { + const cacheDir = mkdtempSync(join(tmpdir(), 'squad-update-check-')); + const cachePath = join(cacheDir, 'update-check.json'); + + beforeEach(() => { + vi.mocked(selfUpdate.getCachePath).mockReturnValue(cachePath); + vi.mocked(selfUpdate.fetchLatestVersion).mockReset(); + vi.mocked(selfUpdate.writeCache).mockReset(); + }); + + afterEach(() => { + try { rmSync(cachePath); } catch { /* no cache file written this test */ } + }); + + it('reports no cache without making a network call', async () => { + const result = await runUpdateCheck('0.11.0'); + expect(result).toEqual({ + current: '0.11.0', + channel: 'stable', + latest: null, + updateAvailable: false, + cacheAge: null, + checkedAt: null, + }); + expect(selfUpdate.fetchLatestVersion).not.toHaveBeenCalled(); + }); + + it('reads an existing cache and reports an available update', async () => { + const checkedAt = Date.now() - 5 * 60 * 1000; + writeFileSync(cachePath, JSON.stringify({ latestVersion: '0.12.0', checkedAt })); + + const result = await runUpdateCheck('0.11.0'); + + expect(result.latest).toBe('0.12.0'); + expect(result.updateAvailable).toBe(true); + expect(result.cacheAge).toBe('PT5M'); + expect(result.checkedAt).toBe(new Date(checkedAt).toISOString()); + expect(selfUpdate.fetchLatestVersion).not.toHaveBeenCalled(); + }); + + it('reports updateAvailable: false when already on the cached latest version', async () => { + writeFileSync(cachePath, JSON.stringify({ latestVersion: '0.11.0', checkedAt: Date.now() })); + + const result = await runUpdateCheck('0.11.0'); + + expect(result.updateAvailable).toBe(false); + }); + + it('bypasses a stale cache and fetches fresh data with refresh: true', async () => { + writeFileSync(cachePath, JSON.stringify({ latestVersion: '0.10.0', checkedAt: Date.now() - 999_999 })); + vi.mocked(selfUpdate.fetchLatestVersion).mockResolvedValue('0.13.0'); + + const result = await runUpdateCheck('0.11.0', { refresh: true }); + + expect(selfUpdate.fetchLatestVersion).toHaveBeenCalledTimes(1); + expect(result.latest).toBe('0.13.0'); + expect(result.updateAvailable).toBe(true); + expect(result.cacheAge).toBe('PT0S'); + expect(selfUpdate.writeCache).toHaveBeenCalledWith( + expect.objectContaining({ latestVersion: '0.13.0' }), + ); + }); + + it('returns an error result when refresh cannot reach the registry', async () => { + vi.mocked(selfUpdate.fetchLatestVersion).mockResolvedValue(null); + + const result = await runUpdateCheck('0.11.0', { refresh: true }); + + expect(result.error).toBeTruthy(); + expect(result.latest).toBeNull(); + expect(selfUpdate.writeCache).not.toHaveBeenCalled(); + }); +}); + +describe('runUpdateCheckCommand()', () => { + const cacheDir = mkdtempSync(join(tmpdir(), 'squad-update-check-cmd-')); + const cachePath = join(cacheDir, 'update-check.json'); + const originalEnv = process.env.SQUAD_NO_UPDATE_CHECK; + + beforeEach(() => { + vi.mocked(selfUpdate.getCachePath).mockReturnValue(cachePath); + vi.mocked(selfUpdate.fetchLatestVersion).mockReset(); + vi.mocked(selfUpdate.writeCache).mockReset(); + }); + + afterEach(() => { + try { rmSync(cachePath); } catch { /* no cache file written this test */ } + if (originalEnv === undefined) delete process.env.SQUAD_NO_UPDATE_CHECK; + else process.env.SQUAD_NO_UPDATE_CHECK = originalEnv; + }); + + it('exits 0 and prints nothing when SQUAD_NO_UPDATE_CHECK=1 (text mode)', async () => { + process.env.SQUAD_NO_UPDATE_CHECK = '1'; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const exitCode = await runUpdateCheckCommand([]); + + expect(exitCode).toBe(0); + expect(logSpy).not.toHaveBeenCalled(); + expect(selfUpdate.fetchLatestVersion).not.toHaveBeenCalled(); + logSpy.mockRestore(); + }); + + it('exits 0 and prints an empty JSON object when SQUAD_NO_UPDATE_CHECK=1 (--json)', async () => { + process.env.SQUAD_NO_UPDATE_CHECK = '1'; + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const exitCode = await runUpdateCheckCommand(['--json']); + + expect(exitCode).toBe(0); + expect(logSpy).toHaveBeenCalledWith('{}'); + logSpy.mockRestore(); + }); + + it('exits 1 when a cached update is available', async () => { + writeFileSync(cachePath, JSON.stringify({ latestVersion: '99.0.0', checkedAt: Date.now() })); + vi.spyOn(console, 'log').mockImplementation(() => {}); + + const exitCode = await runUpdateCheckCommand([]); + + expect(exitCode).toBe(1); + vi.restoreAllMocks(); + }); + + it('exits 0 when there is no cache yet', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + + const exitCode = await runUpdateCheckCommand([]); + + expect(exitCode).toBe(0); + vi.restoreAllMocks(); + }); + + it('exits 2 when --refresh cannot reach the registry', async () => { + vi.mocked(selfUpdate.fetchLatestVersion).mockResolvedValue(null); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + const exitCode = await runUpdateCheckCommand(['--refresh']); + + expect(exitCode).toBe(2); + vi.restoreAllMocks(); + }); + + it('emits parseable JSON matching the documented schema with --json', async () => { + writeFileSync(cachePath, JSON.stringify({ latestVersion: '0.12.0', checkedAt: Date.now() })); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await runUpdateCheckCommand(['--json']); + + const printed = logSpy.mock.calls.map((call) => call[0]).join(''); + const parsed = JSON.parse(printed); + expect(parsed).toMatchObject({ + current: expect.any(String), + channel: expect.stringMatching(/^(stable|preview|insider)$/), + latest: '0.12.0', + updateAvailable: expect.any(Boolean), + cacheAge: expect.any(String), + checkedAt: expect.any(String), + }); + logSpy.mockRestore(); + }); +});