/** * Update checker — detects if a newer version of PAL is available. * * Repo mode: auto-detected when a .git directory exists at palPkg() root. * Package mode: fallback when no .git is present (global bun install). * * Caches result in state/update-available.json. Checked at most once per hour. */ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { logDebug } from "../lib/log"; import { ensureDir, palPkg, paths } from "../lib/paths"; export interface UpdateCache { checkedAt: string; available: boolean; current: string; latest: string; mode: "repo" | "package"; } const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour function cachePath(): string { return resolve(ensureDir(paths.state()), "update-available.json"); } /** The last check's result whatever its age — the notice and the page read this, not the TTL. */ export function cachedStatus(): UpdateCache | null { try { const fp = cachePath(); if (!existsSync(fp)) return null; return JSON.parse(readFileSync(fp, "utf-8")) as UpdateCache; } catch { return null; } } function readCache(): UpdateCache | null { const cache = cachedStatus(); if (!cache) return null; const fresh = Date.now() - new Date(cache.checkedAt).getTime() < CACHE_TTL_MS; return fresh ? cache : null; } function writeCache(cache: UpdateCache): void { try { writeFileSync(cachePath(), JSON.stringify(cache, null, 2), "utf-8"); } catch { /* non-critical */ } } export function isRepoMode(): boolean { return existsSync(resolve(palPkg(), ".git")); } export function getInstalledVersion(): string { try { const pkg = JSON.parse(readFileSync(resolve(palPkg(), "package.json"), "utf-8")); return pkg.version || "0.0.0"; } catch { return "0.0.0"; } } async function checkRepo(): Promise { const repoDir = palPkg(); const current = getInstalledVersion(); try { const fetch = Bun.spawn(["git", "fetch", "--quiet"], { cwd: repoDir, stdout: "ignore", stderr: "ignore", windowsHide: true, }); await fetch.exited; const remote = Bun.spawn(["git", "rev-parse", "origin/main"], { cwd: repoDir, stdout: "pipe", stderr: "ignore", windowsHide: true, }); const remoteHash = (await new Response(remote.stdout).text()).trim(); // "Ahead" (local unpushed commits) is not an update — only remote commits we // lack are. Count HEAD..origin/main so being ahead or diverged never nags. const behind = Bun.spawn(["git", "rev-list", "--count", "HEAD..origin/main"], { cwd: repoDir, stdout: "pipe", stderr: "ignore", windowsHide: true, }); const behindCount = Number.parseInt((await new Response(behind.stdout).text()).trim(), 10) || 0; const available = behindCount > 0 && remoteHash.length > 0; // Get remote version from package.json on origin/main let latest = current; if (available) { try { const show = Bun.spawn(["git", "show", "origin/main:package.json"], { cwd: repoDir, stdout: "pipe", stderr: "ignore", windowsHide: true, }); const remotePkg = JSON.parse(await new Response(show.stdout).text()); latest = remotePkg.version || current; } catch { latest = `${remoteHash.slice(0, 7)} (ahead)`; } } return { checkedAt: new Date().toISOString(), available, current, latest, mode: "repo", }; } catch { return { checkedAt: new Date().toISOString(), available: false, current, latest: current, mode: "repo", }; } } async function checkNpm(): Promise { const current = getInstalledVersion(); try { const response = await fetch( "https://registry.npmjs.org/portable-agent-layer/latest", { signal: AbortSignal.timeout(5000) } ); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = (await response.json()) as { version?: string }; const latest = data.version || current; return { checkedAt: new Date().toISOString(), available: latest !== current, current, latest, mode: "package", }; } catch { return { checkedAt: new Date().toISOString(), available: false, current, latest: current, mode: "package", }; } } /** Run update check. Caches result. Use force=true to skip cache (e.g. for `pal cli update`). */ export async function checkForUpdate(force = false): Promise { if (!force) { const cached = readCache(); if (cached) { logDebug("update-check", "Using cached result"); return cached; } } const result = isRepoMode() ? await checkRepo() : await checkNpm(); writeCache(result); if (result.available) { logDebug( "update-check", `Update available: ${result.current} → ${result.latest} (${result.mode})` ); } return result; } /** Invalidate the cached update status — call after a successful update so the notice clears immediately. */ export function clearUpdateCache(): void { try { rmSync(cachePath(), { force: true }); } catch { /* non-critical */ } } /** Read cached update status for greeting display. Returns null if no update. */ export function getUpdateNotice(): string | null { const cache = cachedStatus(); if (!cache?.available) return null; return `📦 Update available: ${cache.current} → ${cache.latest} (pal cli update)`; }