From 429a4156ca8b7092fdd9e340c12a3cfcb7811d6d Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:04:37 -0700 Subject: [PATCH] feat: add non-blocking version check on CLI startup (Phase 1) Adds a background update check that runs when the interactive shell starts. On startup, Squad checks the npm registry for a newer version and displays a passive notification banner if one is available. Key design decisions: - Fire-and-forget: never blocks or delays shell startup - 24-hour cache: avoids repeated network calls - 3-second fetch timeout via AbortController - Opt-out via SQUAD_NO_UPDATE_CHECK=1 env var - Triple-wrapped error handling: silent on any failure New file: packages/squad-cli/src/cli/self-update.ts Modified: packages/squad-cli/src/cli-entry.ts (wired into no-args shell path) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/squad-cli/src/cli-entry.ts | 2 + packages/squad-cli/src/cli/self-update.ts | 122 ++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 packages/squad-cli/src/cli/self-update.ts diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index dba88bb0d..347cb8d11 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -211,6 +211,8 @@ async function main(): Promise { // No args → launch interactive shell; whitespace-only arg → show help if (rawCmd === undefined) { await checkNodeSqlite(); + // Fire-and-forget update check — non-blocking, never delays shell startup + import('./cli/self-update.js').then(m => m.notifyIfUpdateAvailable(VERSION)).catch(() => {}); const { runShell } = await lazyRunShell(); await runShell(); return; diff --git a/packages/squad-cli/src/cli/self-update.ts b/packages/squad-cli/src/cli/self-update.ts new file mode 100644 index 000000000..f99253067 --- /dev/null +++ b/packages/squad-cli/src/cli/self-update.ts @@ -0,0 +1,122 @@ +/** + * Self-update check — Phase 1: background version check with notification. + * + * Non-blocking startup check that queries the npm registry for the latest + * version of @bradygaster/squad-cli and displays a passive banner when + * an update is available. Results are cached for 24 hours. + * + * Disable with: SQUAD_NO_UPDATE_CHECK=1 + * + * @module cli/self-update + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { compareVersions } from './upgrade.js'; +import { BOLD, RESET, DIM, YELLOW } from './core/output.js'; + +const PACKAGE_NAME = '@bradygaster/squad-cli'; +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 { + latestVersion: string; + checkedAt: number; +} + +/** Directory for squad CLI cache files. */ +function getCacheDir(): string { + const base = process.env.APPDATA + ?? (process.platform === 'darwin' + ? path.join(os.homedir(), 'Library', 'Application Support') + : path.join(os.homedir(), '.config')); + return path.join(base, 'squad-cli'); +} + +function getCachePath(): string { + return path.join(getCacheDir(), 'update-check.json'); +} + +/** Read cached version check result, if still valid. */ +function readCache(): CacheData | null { + try { + const raw = fs.readFileSync(getCachePath(), 'utf8'); + const data: CacheData = JSON.parse(raw); + if (Date.now() - data.checkedAt < CACHE_TTL_MS) { + return data; + } + } catch { + // Cache missing, corrupt, or expired — ignore + } + return null; +} + +/** Write version check result to cache. */ +function writeCache(data: CacheData): void { + try { + const dir = getCacheDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(getCachePath(), JSON.stringify(data), 'utf8'); + } catch { + // Non-critical — silently ignore write failures + } +} + +/** Fetch latest version from npm registry with timeout. */ +async function fetchLatestVersion(): Promise { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const res = await fetch(REGISTRY_URL, { + headers: { Accept: 'application/vnd.npm.install-v1+json' }, + signal: controller.signal, + }); + clearTimeout(timer); + if (!res.ok) return null; + const data = await res.json() as { 'dist-tags'?: { latest?: string } }; + return data['dist-tags']?.latest ?? null; + } catch { + // Network failure, timeout, or parse error — silently ignore + return null; + } +} + +/** + * Check for updates and print a banner if a newer version is available. + * + * This function is designed to be fire-and-forget: it never throws, + * never blocks the shell, and silently no-ops on any failure. + * + * @param currentVersion - The currently running CLI version + */ +export async function notifyIfUpdateAvailable(currentVersion: string): Promise { + try { + // Respect opt-out + if (process.env.SQUAD_NO_UPDATE_CHECK === '1') return; + + // Check cache first + const cached = readCache(); + let latest: string; + + if (cached) { + latest = cached.latestVersion; + } else { + const fetched = await fetchLatestVersion(); + if (!fetched) return; + latest = fetched; + writeCache({ latestVersion: latest, checkedAt: Date.now() }); + } + + // Only notify if strictly newer + if (compareVersions(latest, currentVersion) > 0) { + console.log( + `\n${YELLOW}⚡${RESET} ${BOLD}Squad v${latest}${RESET} available ${DIM}(you have v${currentVersion})${RESET}` + + `\n Run: ${BOLD}npm install -g @bradygaster/squad-cli@latest${RESET}\n`, + ); + } + } catch { + // Absolute safety net — never crash the CLI for an update check + } +}