diff --git a/apps/cli/src/cli.tsx b/apps/cli/src/cli.tsx index 27ebdac..b4b61a8 100644 --- a/apps/cli/src/cli.tsx +++ b/apps/cli/src/cli.tsx @@ -16,17 +16,36 @@ import { runDaemonStatus, runDaemonStop, } from "./commands/daemon.js"; +import { startAutoUpdate } from "./lib/update.js"; const program = new Command(); // Injected at build time by tsup from package.json (see tsup.config.ts); the // `typeof` guard keeps `tsx` dev runs (where it isn't defined) working. declare const __CLI_VERSION__: string; +const CLI_VERSION = typeof __CLI_VERSION__ !== "undefined" ? __CLI_VERSION__ : "0.0.0-dev"; program .name("ccpool") .description("a shared, live picture of one Claude account's usage and who's using it") - .version(typeof __CLI_VERSION__ !== "undefined" ? __CLI_VERSION__ : "0.0.0-dev"); + .version(CLI_VERSION); + +// Background auto-update: detect npm/pnpm/yarn/bun install, pull latest when +// newer. Only fire for the long-lived commands — the interactive TUI (bare +// `ccpool` / `tui` / `live`) and the foreground daemon (`daemon run`) — where the +// process stays alive long enough to finish an install and (in the TUI) surface +// failures on the error line. One-shot commands exit via `process.exitCode` and +// would otherwise block on the registry round-trip or the ~2-minute install; the +// hot statusline must stay cheap too. +const args = process.argv.slice(2); +const isLongLived = + args.length === 0 || // bare `ccpool` opens the TUI + args[0] === "tui" || + args[0] === "live" || + (args[0] === "daemon" && args[1] === "run"); +if (isLongLived) { + startAutoUpdate({ currentVersion: CLI_VERSION }); +} // Bare `ccpool` opens the TUI: onboarding when unconfigured, the live view // otherwise (press `c` there to configure). The subcommands below remain as a diff --git a/apps/cli/src/lib/update.ts b/apps/cli/src/lib/update.ts new file mode 100644 index 0000000..cf48aff --- /dev/null +++ b/apps/cli/src/lib/update.ts @@ -0,0 +1,384 @@ +/** + * Background auto-update for the published `ccpool` CLI. + * + * Detects how this binary was installed (npm / pnpm / yarn / bun global), checks + * the npm registry for a newer version, and applies the matching upgrade command + * without blocking the process. Failures are published to module state so the + * TUI can put them on its error line; successes land on disk for the next run + * (the current process keeps its already-loaded code). + * + * Skips: dev/source runs, npx caches, CI, `CCPOOL_NO_UPDATE=1`, and non-global + * layouts we can't safely rewrite. Throttled via `~/.ccpool/update-check.json` + * so hot paths (statusline) and short commands don't hammer the registry. + */ +import { spawn } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { ccpoolDir } from "./config.js"; + +export type PackageManager = "npm" | "pnpm" | "yarn" | "bun"; + +export type UpdateState = + | { status: "idle" } + | { status: "skipped"; reason: string } + | { status: "checking" } + | { status: "up-to-date"; current: string; latest: string } + | { status: "updating"; current: string; latest: string; manager: PackageManager } + | { status: "updated"; current: string; latest: string; manager: PackageManager } + | { status: "failed"; message: string }; + +const PACKAGE_NAME = "ccpool"; +const REGISTRY_LATEST = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`; +/** Don't re-check the registry more often than this (ms). */ +export const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h +/** Cap how long an install is allowed to run. */ +const UPDATE_TIMEOUT_MS = 120_000; + +const UPDATE_ARGS: Record = { + npm: ["npm", ["install", "-g", `${PACKAGE_NAME}@latest`]], + pnpm: ["pnpm", ["add", "-g", `${PACKAGE_NAME}@latest`]], + yarn: ["yarn", ["global", "add", `${PACKAGE_NAME}@latest`]], + bun: ["bun", ["add", "-g", `${PACKAGE_NAME}@latest`]], +}; + +// ── module state (TUI subscribes) ─────────────────────────────────────────── + +let state: UpdateState = { status: "idle" }; +let started = false; +const listeners = new Set<(s: UpdateState) => void>(); + +function setState(next: UpdateState): void { + state = next; + for (const fn of listeners) { + try { + fn(next); + } catch { + // subscriber bugs must not break the update loop + } + } +} + +export function getUpdateState(): UpdateState { + return state; +} + +/** Subscribe to state changes; called immediately with the current state. */ +export function subscribeUpdate(fn: (s: UpdateState) => void): () => void { + listeners.add(fn); + fn(state); + return () => { + listeners.delete(fn); + }; +} + +/** Test seam — reset the singleton so suites don't leak across files. */ +export function _resetUpdateForTests(): void { + state = { status: "idle" }; + started = false; + listeners.clear(); +} + +// ── version compare ───────────────────────────────────────────────────────── + +/** True when `latest` is a strictly newer semver than `current` (prerelease ignored). */ +export function isNewerVersion(latest: string, current: string): boolean { + const parse = (v: string): number[] => + v + .replace(/^v/i, "") + .split("-")[0]! + .split(".") + .map((p) => { + const n = parseInt(p, 10); + return Number.isFinite(n) ? n : 0; + }); + const a = parse(latest); + const b = parse(current); + const len = Math.max(a.length, b.length, 3); + for (let i = 0; i < len; i++) { + const av = a[i] ?? 0; + const bv = b[i] ?? 0; + if (av > bv) return true; + if (av < bv) return false; + } + return false; +} + +// ── install-source detection ──────────────────────────────────────────────── + +/** + * Infer the package manager that owns this install from the resolved path of + * the running entry script (`process.argv[1]`). Returns null when we shouldn't + * (or can't) auto-update — source checkouts, npx caches, unknown layouts. + */ +export function detectPackageManager( + entryPath: string | undefined = process.argv[1] +): PackageManager | null { + if (!entryPath) return null; + + let real: string; + try { + real = realpathSync(entryPath); + } catch { + // unresolved symlink / vanished path — not a managed install we can rewrite + return null; + } + + // Normalize for case-insensitive FS and Windows separators. + const p = real.replace(/\\/g, "/").toLowerCase(); + + // Dev / monorepo: running source or a local build, not a published global. + if (p.includes("/apps/cli/src/") || p.endsWith(".tsx")) return null; + if (p.includes("/apps/cli/dist/") && !p.includes("/node_modules/")) return null; + + // One-shot npx / bunx caches — upgrading those would write elsewhere or nowhere useful. + if (p.includes("/_npx/") || p.includes("/.npm/_npx/")) return null; + if (p.includes("/.bun/install/cache/")) return null; + + // Global-install path markers (most specific first). These deliberately match + // only *global* layouts — a bare /node_modules/ccpool (or a local + // pnpm/yarn virtual store) is a local dependency we must never rewrite with a + // `-g` upgrade. + if (p.includes("/.bun/install/global/") || p.includes("/bun/install/global/")) return "bun"; + if (p.includes("/pnpm/global/") || p.includes("/pnpm-global/")) return "pnpm"; + if (p.includes("/.yarn/global/") || p.includes("/yarn/global/")) return "yarn"; + + // Classic npm global lives under /lib/node_modules (POSIX) or + // /npm/node_modules (Windows). A plain /node_modules/ccpool + // matches neither, so it stays unmanaged. + if (/\/node_modules\/ccpool(\/|$)/.test(p)) { + // Prefer yarn when the tree clearly lives under a yarn global prefix. + if (p.includes("/yarn/")) return "yarn"; + if (p.includes("/lib/node_modules/ccpool") || p.includes("/npm/node_modules/ccpool")) + return "npm"; + return null; + } + + return null; +} + +/** The install command a user (or we) would run for this manager. */ +export function updateCommand(manager: PackageManager): string { + const [cmd, args] = UPDATE_ARGS[manager]; + return [cmd, ...args].join(" "); +} + +// ── throttle file ─────────────────────────────────────────────────────────── + +interface CheckRecord { + lastCheckAt: number; + lastLatest?: string; + lastResult?: "up-to-date" | "updated" | "failed" | "skipped"; +} + +function checkPath(env: NodeJS.ProcessEnv = process.env): string { + return join(ccpoolDir(env), "update-check.json"); +} + +async function readCheckRecord(env: NodeJS.ProcessEnv): Promise { + try { + const raw = await readFile(checkPath(env), "utf8"); + const j = JSON.parse(raw) as CheckRecord; + if (typeof j.lastCheckAt !== "number") return null; + return j; + } catch { + return null; + } +} + +async function writeCheckRecord(rec: CheckRecord, env: NodeJS.ProcessEnv): Promise { + const p = checkPath(env); + await mkdir(dirname(p), { recursive: true }); + await writeFile(p, JSON.stringify(rec, null, 2) + "\n", "utf8"); +} + +// ── registry + install ────────────────────────────────────────────────────── + +export interface UpdateDeps { + fetchLatest: () => Promise; + runInstall: (manager: PackageManager) => Promise; + now: () => number; + env: NodeJS.ProcessEnv; + entryPath?: string; +} + +async function defaultFetchLatest(): Promise { + const res = await fetch(REGISTRY_LATEST, { + headers: { accept: "application/json", "user-agent": "ccpool-cli" }, + // Don't hang a long-lived TUI on a wedged registry. + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`registry returned HTTP ${res.status}`); + const body = (await res.json()) as { version?: string }; + if (typeof body.version !== "string" || !body.version) { + throw new Error("registry response missing version"); + } + return body.version; +} + +function defaultRunInstall(manager: PackageManager): Promise { + const [cmd, args] = UPDATE_ARGS[manager]; + return new Promise((resolve, reject) => { + const child = spawn(cmd, [...args], { + stdio: ["ignore", "pipe", "pipe"], + env: process.env, + // windowsHide keeps a console window from flashing on Win32 + windowsHide: true, + }); + let stderr = ""; + let stdout = ""; + child.stderr?.on("data", (d: Buffer | string) => { + stderr += String(d); + }); + child.stdout?.on("data", (d: Buffer | string) => { + stdout += String(d); + }); + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error(`update timed out after ${UPDATE_TIMEOUT_MS / 1000}s`)); + }, UPDATE_TIMEOUT_MS); + child.on("error", (err) => { + clearTimeout(timer); + reject( + new Error( + err.message.includes("ENOENT") + ? `${cmd} not found on PATH — install it or reinstall ccpool with your package manager` + : err.message + ) + ); + }); + child.on("close", (code) => { + clearTimeout(timer); + if (code === 0) { + resolve(); + return; + } + const detail = (stderr || stdout).trim().split("\n").filter(Boolean).slice(-3).join(" · "); + reject(new Error(detail || `${cmd} exited with code ${code ?? "?"}`)); + }); + }); +} + +function defaultDeps(): UpdateDeps { + return { + fetchLatest: defaultFetchLatest, + runInstall: defaultRunInstall, + now: () => Date.now(), + env: process.env, + entryPath: process.argv[1], + }; +} + +export interface StartAutoUpdateOptions { + /** Current CLI version (injected at build as `__CLI_VERSION__`). */ + currentVersion: string; + /** Force a check even if the throttle file says we checked recently. */ + force?: boolean; + /** Override I/O for tests. */ + deps?: Partial; +} + +/** + * Kick off a background check + optional install. Idempotent per process: the + * first call wins, later calls no-op. Safe to call from the CLI entry before the + * TUI mounts — state is module-level and the TUI can subscribe later. + */ +export function startAutoUpdate(opts: StartAutoUpdateOptions): void { + if (started) return; + started = true; + void runAutoUpdate(opts).catch((err) => { + // Last-resort: never let an unhandled rejection take down the CLI. + const message = err instanceof Error ? err.message : String(err); + setState({ status: "failed", message: `update failed: ${message}` }); + }); +} + +/** Awaitable core — exported for tests. Prefer `startAutoUpdate` in production. */ +export async function runAutoUpdate(opts: StartAutoUpdateOptions): Promise { + const deps: UpdateDeps = { ...defaultDeps(), ...opts.deps }; + const current = opts.currentVersion; + + if (!current || current === "0.0.0-dev" || current.endsWith("-dev")) { + const s: UpdateState = { status: "skipped", reason: "dev build" }; + setState(s); + return s; + } + if (deps.env.CCPOOL_NO_UPDATE === "1" || deps.env.CCPOOL_NO_UPDATE === "true") { + const s: UpdateState = { status: "skipped", reason: "CCPOOL_NO_UPDATE" }; + setState(s); + return s; + } + if (deps.env.CI === "true" || deps.env.CI === "1") { + const s: UpdateState = { status: "skipped", reason: "CI" }; + setState(s); + return s; + } + + const manager = detectPackageManager(deps.entryPath); + if (!manager) { + const s: UpdateState = { status: "skipped", reason: "unmanaged install" }; + setState(s); + return s; + } + + if (!opts.force) { + const prev = await readCheckRecord(deps.env); + if (prev && deps.now() - prev.lastCheckAt < CHECK_INTERVAL_MS) { + const s: UpdateState = { status: "skipped", reason: "checked recently" }; + setState(s); + return s; + } + } + + setState({ status: "checking" }); + + let latest: string; + try { + latest = await deps.fetchLatest(); + } catch (err) { + const message = `update check failed: ${(err as Error).message}`; + setState({ status: "failed", message }); + await writeCheckRecord({ lastCheckAt: deps.now(), lastResult: "failed" }, deps.env).catch( + () => undefined + ); + return getUpdateState(); + } + + if (!isNewerVersion(latest, current)) { + const s: UpdateState = { status: "up-to-date", current, latest }; + setState(s); + await writeCheckRecord( + { lastCheckAt: deps.now(), lastLatest: latest, lastResult: "up-to-date" }, + deps.env + ).catch(() => undefined); + return s; + } + + setState({ status: "updating", current, latest, manager }); + + try { + await deps.runInstall(manager); + } catch (err) { + const detail = (err as Error).message; + const message = `update to ${latest} failed (${updateCommand(manager)}): ${detail}`; + setState({ status: "failed", message }); + await writeCheckRecord( + { lastCheckAt: deps.now(), lastLatest: latest, lastResult: "failed" }, + deps.env + ).catch(() => undefined); + return getUpdateState(); + } + + const s: UpdateState = { status: "updated", current, latest, manager }; + setState(s); + await writeCheckRecord( + { lastCheckAt: deps.now(), lastLatest: latest, lastResult: "updated" }, + deps.env + ).catch(() => undefined); + return s; +} + +/** Human-readable error line text, or null when there's nothing to show. */ +export function updateErrorMessage(s: UpdateState = state): string | null { + return s.status === "failed" ? s.message : null; +} diff --git a/apps/cli/src/tui/App.tsx b/apps/cli/src/tui/App.tsx index 6bd547e..07d93b5 100644 --- a/apps/cli/src/tui/App.tsx +++ b/apps/cli/src/tui/App.tsx @@ -4,6 +4,7 @@ import { CAP_KINDS, type Config, type ViewSource } from "@ccpool/core"; import { gatherView, type LastRoster } from "../lib/view.js"; import { toDesignModel, type DesignModel } from "../lib/design-model.js"; import { loadConfig } from "../lib/config.js"; +import { subscribeUpdate, updateErrorMessage } from "../lib/update.js"; import { DESIGNS } from "./designs/index.js"; import { renderHistory, type HistoryState } from "./history.js"; import { P } from "./designs/palette.js"; @@ -53,6 +54,9 @@ export function App({ const [model, setModel] = useState(null); const [, setTick] = useState(0); const [err, setErr] = useState(null); + // Auto-update failures (install/check) — separate from view-poll errors so a + // successful refresh doesn't clear a still-relevant update problem. + const [updateErr, setUpdateErr] = useState(() => updateErrorMessage()); const [idx, setIdx] = useState(0); const [scroll, setScroll] = useState(0); const [msg, setMsg] = useState(0); @@ -128,6 +132,14 @@ export function App({ }; }, [refresh, pollIntervalMs]); + // Background auto-update (started from cli.tsx) publishes failures here so they + // stick on the error line while the TUI keeps running. + useEffect(() => { + return subscribeUpdate((s) => { + setUpdateErr(updateErrorMessage(s)); + }); + }, []); + const innerCols = cols - 2; const n = DESIGNS.length; @@ -136,7 +148,9 @@ export function App({ const shortcutsText = mode === "history" ? SHORTCUTS_HISTORY : onConfigure ? SHORTCUTS_CONFIG : SHORTCUTS; const wide = innerCols >= current.label.length + shortcutsText.length + 4; - const footerH = wide ? 1 : 2; + // Prefer the live view error; fall back to an auto-update failure. + const errorLine = err ?? updateErr; + const footerH = (wide ? 1 : 2) + (errorLine ? 1 : 0); // Leave the last terminal row unused so output stays strictly shorter than the // terminal. At full height Ink clears the whole screen every frame (outputHeight // >= stdout.rows), which flickers; one row of slack keeps the incremental path. @@ -221,7 +235,7 @@ export function App({ loading… )} - {err ? error: {err} : null} + {errorLine ? error: {errorLine} : null} {wide ? ( {message} diff --git a/apps/cli/test/update.test.ts b/apps/cli/test/update.test.ts new file mode 100644 index 0000000..31bd3ba --- /dev/null +++ b/apps/cli/test/update.test.ts @@ -0,0 +1,372 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + _resetUpdateForTests, + CHECK_INTERVAL_MS, + detectPackageManager, + getUpdateState, + isNewerVersion, + runAutoUpdate, + startAutoUpdate, + subscribeUpdate, + updateCommand, + updateErrorMessage, + type PackageManager, +} from "../src/lib/update.js"; + +let ccpoolDir: string; + +beforeEach(() => { + _resetUpdateForTests(); + ccpoolDir = mkdtempSync(join(tmpdir(), "ccpool-update-")); + process.env.CCPOOL_DIR = ccpoolDir; + delete process.env.CCPOOL_NO_UPDATE; + delete process.env.CI; +}); + +afterEach(() => { + _resetUpdateForTests(); + delete process.env.CCPOOL_DIR; + delete process.env.CCPOOL_NO_UPDATE; + delete process.env.CI; +}); + +/** Create a fake entry file and return its path (optionally under a themed layout). */ +function fakeEntry(segments: string[]): string { + const file = join(ccpoolDir, ...segments); + mkdirSync(join(file, ".."), { recursive: true }); + writeFileSync(file, "#!/usr/bin/env node\n", "utf8"); + return file; +} + +describe("isNewerVersion", () => { + it("compares dotted semver numerically", () => { + expect(isNewerVersion("0.0.4", "0.0.3")).toBe(true); + expect(isNewerVersion("0.0.3", "0.0.3")).toBe(false); + expect(isNewerVersion("0.0.2", "0.0.3")).toBe(false); + expect(isNewerVersion("1.0.0", "0.9.9")).toBe(true); + expect(isNewerVersion("0.10.0", "0.9.0")).toBe(true); + }); + + it("strips a leading v and ignores prerelease suffixes for the core compare", () => { + expect(isNewerVersion("v1.2.3", "1.2.2")).toBe(true); + expect(isNewerVersion("1.2.3-beta", "1.2.2")).toBe(true); + }); +}); + +describe("detectPackageManager", () => { + it("returns null for empty / unreadable paths", () => { + // empty string is an explicit miss; `undefined` would fall through to the + // default (`process.argv[1]`) which is how production calls this. + expect(detectPackageManager("")).toBeNull(); + expect(detectPackageManager(join(ccpoolDir, "nope", "cli.js"))).toBeNull(); + }); + + it("skips monorepo source and local dist builds", () => { + expect(detectPackageManager(fakeEntry(["apps", "cli", "src", "cli.tsx"]))).toBeNull(); + expect(detectPackageManager(fakeEntry(["apps", "cli", "dist", "cli.js"]))).toBeNull(); + }); + + it("skips npx caches", () => { + expect( + detectPackageManager( + fakeEntry([".npm", "_npx", "abc", "node_modules", "ccpool", "dist", "cli.js"]) + ) + ).toBeNull(); + }); + + it("detects bun, pnpm, yarn, and npm global layouts", () => { + expect( + detectPackageManager( + fakeEntry([".bun", "install", "global", "node_modules", "ccpool", "dist", "cli.js"]) + ) + ).toBe("bun"); + expect( + detectPackageManager( + fakeEntry([ + ".local", + "share", + "pnpm", + "global", + "5", + ".pnpm", + "ccpool@1.0.0", + "node_modules", + "ccpool", + "dist", + "cli.js", + ]) + ) + ).toBe("pnpm"); + expect( + detectPackageManager( + fakeEntry([".config", "yarn", "global", "node_modules", "ccpool", "dist", "cli.js"]) + ) + ).toBe("yarn"); + expect( + detectPackageManager( + fakeEntry([ + ".nvm", + "versions", + "node", + "v22.0.0", + "lib", + "node_modules", + "ccpool", + "dist", + "cli.js", + ]) + ) + ).toBe("npm"); + }); + + it("skips local (non-global) dependency layouts we must not `-g` upgrade", () => { + // A plain /node_modules/ccpool is a local dep, not a global install. + expect( + detectPackageManager(fakeEntry(["myapp", "node_modules", "ccpool", "dist", "cli.js"])) + ).toBeNull(); + // A project's local pnpm virtual store must not read as a global pnpm install. + expect( + detectPackageManager( + fakeEntry([ + "myapp", + "node_modules", + ".pnpm", + "ccpool@1.0.0", + "node_modules", + "ccpool", + "dist", + "cli.js", + ]) + ) + ).toBeNull(); + // Yarn Berry keeps a project-local .yarn/ dir — not a global install either. + expect( + detectPackageManager(fakeEntry(["myapp", ".yarn", "unplugged", "ccpool", "dist", "cli.js"])) + ).toBeNull(); + }); + + it("follows symlinks to the real package tree", () => { + const real = fakeEntry([ + ".local", + "share", + "pnpm", + "global", + "5", + ".pnpm", + "ccpool@0.0.3", + "node_modules", + "ccpool", + "dist", + "cli.js", + ]); + const link = join(ccpoolDir, "bin", "ccpool"); + mkdirSync(join(ccpoolDir, "bin"), { recursive: true }); + symlinkSync(real, link); + expect(detectPackageManager(link)).toBe("pnpm"); + }); +}); + +describe("updateCommand", () => { + it("returns the right global upgrade for each manager", () => { + const expected: Record = { + npm: "npm install -g ccpool@latest", + pnpm: "pnpm add -g ccpool@latest", + yarn: "yarn global add ccpool@latest", + bun: "bun add -g ccpool@latest", + }; + for (const [pm, cmd] of Object.entries(expected)) { + expect(updateCommand(pm as PackageManager)).toBe(cmd); + } + }); +}); + +describe("runAutoUpdate", () => { + const npmEntry = () => fakeEntry(["lib", "node_modules", "ccpool", "dist", "cli.js"]); + + it("skips dev builds, CI, and CCPOOL_NO_UPDATE", async () => { + await expect( + runAutoUpdate({ currentVersion: "0.0.0-dev", deps: { entryPath: npmEntry() } }) + ).resolves.toMatchObject({ status: "skipped", reason: "dev build" }); + + process.env.CI = "true"; + await expect( + runAutoUpdate({ currentVersion: "0.0.3", deps: { entryPath: npmEntry() } }) + ).resolves.toMatchObject({ status: "skipped", reason: "CI" }); + delete process.env.CI; + + process.env.CCPOOL_NO_UPDATE = "1"; + await expect( + runAutoUpdate({ currentVersion: "0.0.3", deps: { entryPath: npmEntry() } }) + ).resolves.toMatchObject({ status: "skipped", reason: "CCPOOL_NO_UPDATE" }); + }); + + it("skips unmanaged installs", async () => { + const s = await runAutoUpdate({ + currentVersion: "0.0.3", + deps: { entryPath: fakeEntry(["apps", "cli", "dist", "cli.js"]) }, + }); + expect(s).toMatchObject({ status: "skipped", reason: "unmanaged install" }); + }); + + it("reports up-to-date without installing", async () => { + const runInstall = vi.fn(); + const s = await runAutoUpdate({ + currentVersion: "0.0.3", + force: true, + deps: { + entryPath: npmEntry(), + fetchLatest: async () => "0.0.3", + runInstall, + }, + }); + expect(s).toEqual({ status: "up-to-date", current: "0.0.3", latest: "0.0.3" }); + expect(runInstall).not.toHaveBeenCalled(); + }); + + it("installs when the registry is ahead", async () => { + const runInstall = vi.fn(async () => undefined); + const s = await runAutoUpdate({ + currentVersion: "0.0.3", + force: true, + deps: { + entryPath: npmEntry(), + fetchLatest: async () => "0.0.4", + runInstall, + }, + }); + expect(s).toEqual({ + status: "updated", + current: "0.0.3", + latest: "0.0.4", + manager: "npm", + }); + expect(runInstall).toHaveBeenCalledWith("npm"); + }); + + it("surfaces install failures on the error-line helper", async () => { + const s = await runAutoUpdate({ + currentVersion: "0.0.3", + force: true, + deps: { + entryPath: npmEntry(), + fetchLatest: async () => "0.0.4", + runInstall: async () => { + throw new Error("EACCES: permission denied"); + }, + }, + }); + expect(s.status).toBe("failed"); + expect(updateErrorMessage(s)).toContain("update to 0.0.4 failed"); + expect(updateErrorMessage(s)).toContain("npm install -g ccpool@latest"); + expect(updateErrorMessage(s)).toContain("EACCES"); + expect(updateErrorMessage()).toBe(updateErrorMessage(s)); + }); + + it("surfaces registry failures", async () => { + const s = await runAutoUpdate({ + currentVersion: "0.0.3", + force: true, + deps: { + entryPath: npmEntry(), + fetchLatest: async () => { + throw new Error("network down"); + }, + }, + }); + expect(s).toEqual({ status: "failed", message: "update check failed: network down" }); + }); + + it("throttles repeat checks within the interval", async () => { + const fetchLatest = vi.fn(async () => "0.0.3"); + const now = vi.fn(() => 1_000_000); + await runAutoUpdate({ + currentVersion: "0.0.3", + deps: { entryPath: npmEntry(), fetchLatest, now }, + }); + expect(fetchLatest).toHaveBeenCalledTimes(1); + + // Second call without force, same clock → skipped. + const s = await runAutoUpdate({ + currentVersion: "0.0.3", + deps: { entryPath: npmEntry(), fetchLatest, now }, + }); + expect(s).toMatchObject({ status: "skipped", reason: "checked recently" }); + expect(fetchLatest).toHaveBeenCalledTimes(1); + + // Past the throttle window → checks again. + now.mockReturnValue(1_000_000 + CHECK_INTERVAL_MS + 1); + await runAutoUpdate({ + currentVersion: "0.0.3", + deps: { entryPath: npmEntry(), fetchLatest, now }, + }); + expect(fetchLatest).toHaveBeenCalledTimes(2); + }); + + it("uses pnpm's install command when the entry lives under .pnpm", async () => { + const runInstall = vi.fn(async () => undefined); + const entry = fakeEntry([ + "pnpm", + "global", + "5", + ".pnpm", + "ccpool@0.0.3", + "node_modules", + "ccpool", + "dist", + "cli.js", + ]); + const s = await runAutoUpdate({ + currentVersion: "0.0.3", + force: true, + deps: { + entryPath: entry, + fetchLatest: async () => "1.0.0", + runInstall, + }, + }); + expect(s).toMatchObject({ status: "updated", manager: "pnpm" }); + expect(runInstall).toHaveBeenCalledWith("pnpm"); + }); +}); + +describe("startAutoUpdate + subscribeUpdate", () => { + it("is idempotent and notifies subscribers of the final state", async () => { + const entry = fakeEntry(["lib", "node_modules", "ccpool", "dist", "cli.js"]); + const seen: string[] = []; + const unsub = subscribeUpdate((s) => seen.push(s.status)); + + startAutoUpdate({ + currentVersion: "0.0.3", + force: true, + deps: { + entryPath: entry, + fetchLatest: async () => "0.0.3", + runInstall: async () => undefined, + }, + }); + // Second call must no-op (idempotent). + startAutoUpdate({ + currentVersion: "0.0.3", + force: true, + deps: { + entryPath: entry, + fetchLatest: async () => { + throw new Error("should not run"); + }, + }, + }); + + // Wait for the background task. + await vi.waitFor(() => { + expect(getUpdateState().status).toBe("up-to-date"); + }); + + unsub(); + expect(seen).toContain("checking"); + expect(seen).toContain("up-to-date"); + expect(updateErrorMessage()).toBeNull(); + }); +});