From 93a90f147e4074347ea8a223b7074663c1c48f07 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 15:23:45 -0700 Subject: [PATCH] Show install-specific upgrade guidance on TUI start When a newer GitHub release exists, surface a non-blocking notice with the running version, latest version, and the right upgrade step for Homebrew, source/Bun, deb, release binary, or unknown. Network and detection failures skip quietly so startup never blocks. --- src/tui/runner.ts | 11 ++ src/upgrade/index.test.ts | 255 ++++++++++++++++++++++++++++++++ src/upgrade/index.ts | 304 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 570 insertions(+) create mode 100644 src/upgrade/index.test.ts create mode 100644 src/upgrade/index.ts diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 036612de8..fe8d3ad08 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -110,6 +110,7 @@ import { getTelemetry, liveTelemetry, setTelemetry } from "../telemetry/singleto import { createTelemetryToggleHandler } from "../telemetry/toggle.js"; import { loadStartupChangelogMarkdown } from "../changelog/index.js"; +import { scheduleUpgradeNotice } from "../upgrade/index.js"; import pkg from "../../package.json" with { type: "json" }; import { seedPricingMetadataFromCache } from "../cost/pricing-metadata.js"; import { defaultPricingCachePath } from "../cost/pricing-fetcher.js"; @@ -2502,6 +2503,16 @@ export async function runTUI(initialConfig: Config): Promise { for (const notice of startupPluginNotices) surfaceSystemNotice(host.shell, notice); + // Soft upgrade check: never blocks startup; offline / rate-limit is a quiet skip. + // surfaceSystemNotice keeps the landing hero up and flushes into the transcript + // once a session row ends the landing (same path as plugin/MCP startup chatter). + scheduleUpgradeNotice({ + notify: (text) => surfaceSystemNotice(host.shell, text), + options: { + currentVersion: typeof pkg.version === "string" ? pkg.version : "0.0.0", + }, + }); + await host.waitUntilExit(); clearInterval(fleetStallPoll); if (fleetSettle !== null) clearTimeout(fleetSettle); diff --git a/src/upgrade/index.test.ts b/src/upgrade/index.test.ts new file mode 100644 index 000000000..dea41da2d --- /dev/null +++ b/src/upgrade/index.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, test } from "bun:test"; + +import { + BREW_FORMULA, + DEB_PACKAGE, + RELEASES_URL, + checkForUpgrade, + compareVersionStrings, + detectInstallMethod, + formatUpgradeMessage, + type InstallProbe, +} from "./index.js"; + +function probe(partial: Partial & Pick): InstallProbe { + return { + argv: [], + platform: "darwin", + pathExists: () => false, + env: {}, + ...partial, + }; +} + +describe("compareVersionStrings", () => { + test("orders major.minor.patch and strips a leading v", () => { + expect(compareVersionStrings("0.2.95", "0.2.94")).toBeGreaterThan(0); + expect(compareVersionStrings("0.2.94", "0.2.95")).toBeLessThan(0); + expect(compareVersionStrings("0.2.95", "0.2.95")).toBe(0); + expect(compareVersionStrings("v0.3.0", "0.2.99")).toBeGreaterThan(0); + }); + + test("returns null for unparseable input", () => { + expect(compareVersionStrings("not-a-version", "0.1.0")).toBeNull(); + expect(compareVersionStrings("0.1.0", "")).toBeNull(); + }); +}); + +describe("detectInstallMethod", () => { + test("detects Homebrew Cellar installs", () => { + expect( + detectInstallMethod( + probe({ + execPath: "/opt/homebrew/Cellar/corbits-code/0.2.95/bin/corbits", + }), + ), + ).toBe("homebrew"); + expect( + detectInstallMethod( + probe({ + execPath: "/usr/local/bin/corbits", + resolvedPath: "/usr/local/Cellar/corbits-code/0.2.90/bin/corbits", + }), + ), + ).toBe("homebrew"); + }); + + test("detects Homebrew via HOMEBREW_PREFIX when the binary lives under it", () => { + expect( + detectInstallMethod( + probe({ + execPath: "/opt/homebrew/bin/corbits", + env: { HOMEBREW_PREFIX: "/opt/homebrew" }, + }), + ), + ).toBe("homebrew"); + }); + + test("detects Debian package installs", () => { + expect( + detectInstallMethod( + probe({ + execPath: "/usr/bin/corbits", + platform: "linux", + pathExists: (p) => p === `/var/lib/dpkg/info/${DEB_PACKAGE}.list`, + }), + ), + ).toBe("deb"); + expect( + detectInstallMethod( + probe({ + execPath: "/usr/bin/corbits", + platform: "linux", + pathExists: (p) => p === `/usr/share/doc/${DEB_PACKAGE}`, + }), + ), + ).toBe("deb"); + }); + + test("detects Bun / from-source runs", () => { + expect( + detectInstallMethod( + probe({ + execPath: "/Users/dev/.bun/bin/bun", + argv: ["bun", "/repo/corbits-code/src/index.ts"], + }), + ), + ).toBe("source"); + expect( + detectInstallMethod( + probe({ + execPath: "/usr/local/bin/bun", + argv: ["bun", "/repo/dist/index.js"], + }), + ), + ).toBe("source"); + // brew-installed bun must not look like a brew-installed corbits + expect( + detectInstallMethod( + probe({ + execPath: "/opt/homebrew/bin/bun", + argv: ["bun", "/repo/src/index.ts"], + env: { HOMEBREW_PREFIX: "/opt/homebrew" }, + }), + ), + ).toBe("source"); + }); + + test("detects standalone release binaries", () => { + expect( + detectInstallMethod( + probe({ + execPath: "/home/user/.local/bin/corbits", + platform: "linux", + }), + ), + ).toBe("binary"); + expect( + detectInstallMethod( + probe({ + execPath: "/Users/dev/bin/corbits", + platform: "darwin", + }), + ), + ).toBe("binary"); + }); + + test("falls back to unknown rather than guessing brew", () => { + expect( + detectInstallMethod( + probe({ + execPath: "/mysterious/path/agent-runner", + argv: ["agent-runner"], + }), + ), + ).toBe("unknown"); + }); +}); + +describe("formatUpgradeMessage", () => { + const base = { current: "0.2.90", latest: "0.2.95" }; + + test("homebrew message uses the live formula upgrade", () => { + const msg = formatUpgradeMessage({ ...base, method: "homebrew" }); + expect(msg).toContain("v0.2.90 → v0.2.95"); + expect(msg).toContain(`brew update && brew upgrade ${BREW_FORMULA}`); + expect(msg).not.toContain("dpkg"); + }); + + test("source message points at pull + bun rebuild", () => { + const msg = formatUpgradeMessage({ ...base, method: "source" }); + expect(msg).toContain("bun install"); + expect(msg).toContain("bun run start"); + expect(msg).not.toContain("brew upgrade"); + }); + + test("binary message points at the GitHub releases page", () => { + const msg = formatUpgradeMessage({ ...base, method: "binary" }); + expect(msg).toContain(`${RELEASES_URL}/latest`); + expect(msg).not.toContain("brew upgrade"); + expect(msg).not.toContain("dpkg"); + }); + + test("deb message points at dpkg install of the release artifact", () => { + const msg = formatUpgradeMessage({ ...base, method: "deb" }); + expect(msg).toContain("dpkg -i"); + expect(msg).toContain(`${DEB_PACKAGE}_0.2.95_`); + expect(msg).not.toContain("brew upgrade"); + }); + + test("unknown message is generic — no brew or apt command", () => { + const msg = formatUpgradeMessage({ ...base, method: "unknown" }); + expect(msg).toContain(RELEASES_URL); + expect(msg).not.toContain("brew"); + expect(msg).not.toContain("dpkg"); + expect(msg).not.toContain("apt"); + }); +}); + +describe("checkForUpgrade", () => { + test("reports available when latest is newer", async () => { + const result = await checkForUpgrade({ + currentVersion: "0.2.90", + method: "homebrew", + fetchLatest: async () => "0.2.95", + }); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(result.notice.current).toBe("0.2.90"); + expect(result.notice.latest).toBe("0.2.95"); + expect(result.notice.method).toBe("homebrew"); + expect(result.notice.message).toContain("brew upgrade"); + }); + + test("reports current when running the latest (or newer)", async () => { + expect( + ( + await checkForUpgrade({ + currentVersion: "0.2.95", + fetchLatest: async () => "0.2.95", + }) + ).kind, + ).toBe("current"); + expect( + ( + await checkForUpgrade({ + currentVersion: "0.3.0", + fetchLatest: async () => "0.2.95", + }) + ).kind, + ).toBe("current"); + }); + + test("soft-skips when the network probe fails", async () => { + const result = await checkForUpgrade({ + currentVersion: "0.2.90", + fetchLatest: async () => null, + }); + expect(result).toEqual({ kind: "skipped", reason: "latest version unavailable" }); + }); + + test("soft-skips when fetchLatest throws", async () => { + const result = await checkForUpgrade({ + currentVersion: "0.2.90", + fetchLatest: async () => { + throw new Error("offline"); + }, + }); + expect(result.kind).toBe("skipped"); + }); + + test("detects method from probe when not forced", async () => { + const result = await checkForUpgrade({ + currentVersion: "0.1.0", + fetchLatest: async () => "0.2.0", + probe: probe({ + execPath: "/Users/dev/.bun/bin/bun", + argv: ["bun", "/repo/src/index.ts"], + }), + }); + expect(result.kind).toBe("available"); + if (result.kind !== "available") return; + expect(result.notice.method).toBe("source"); + expect(result.notice.message).toContain("bun install"); + }); +}); diff --git a/src/upgrade/index.ts b/src/upgrade/index.ts new file mode 100644 index 000000000..ced855bd6 --- /dev/null +++ b/src/upgrade/index.ts @@ -0,0 +1,304 @@ +/** + * Soft upgrade check for interactive TUI start. + * + * Pure helpers detect how the running build was installed, compare the package + * version to the latest GitHub release, and format a method-specific notice. + * Network and detection failures never throw to callers: the check returns + * `skipped` so startup is never blocked or errored loudly. + */ + +import { basename } from "node:path"; +import { existsSync } from "node:fs"; + +import { + compareVersions, + parseVersionString, +} from "../changelog/index.js"; +import { COMMAND_NAME, PRODUCT_NAME } from "../branding.js"; +import pkg from "../../package.json" with { type: "json" }; + +/** Public releases page (human-facing). */ +export const RELEASES_URL = + "https://github.com/corbitsdev/corbits-code/releases"; + +/** GitHub API endpoint for the latest published release. */ +export const RELEASES_LATEST_API = + "https://api.github.com/repos/corbitsdev/corbits-code/releases/latest"; + +/** Homebrew formula name (matches `scripts/release.sh` BREW_FORMULA). */ +export const BREW_FORMULA = "corbits-code"; + +/** Debian package name (matches `scripts/release.sh` Package: field). */ +export const DEB_PACKAGE = "corbits"; + +/** Bound the network probe so a hung API cannot stall the session. */ +export const UPGRADE_FETCH_TIMEOUT_MS = 4_000; + +export type InstallMethod = + | "homebrew" + | "deb" + | "binary" + | "source" + | "unknown"; + +export type UpgradeNotice = { + readonly current: string; + readonly latest: string; + readonly method: InstallMethod; + readonly message: string; +}; + +export type UpgradeCheckResult = + | { readonly kind: "available"; readonly notice: UpgradeNotice } + | { readonly kind: "current" } + | { readonly kind: "skipped"; readonly reason: string }; + +/** Inputs for install-method detection — injectable for tests. */ +export type InstallProbe = { + readonly execPath: string; + readonly argv: readonly string[]; + readonly platform: NodeJS.Platform; + /** Lowercased path string used for marker matching (realpath when available). */ + readonly resolvedPath?: string; + readonly pathExists?: (path: string) => boolean; + readonly env?: NodeJS.ProcessEnv; +}; + +export type FetchLatestVersion = () => Promise; + +export type UpgradeCheckOptions = { + readonly currentVersion?: string; + readonly method?: InstallMethod; + readonly probe?: InstallProbe; + readonly fetchLatest?: FetchLatestVersion; +}; + +function normalizeVersion(raw: string): string | null { + const parsed = parseVersionString(raw); + if (parsed === null) return null; + return `${parsed.major}.${parsed.minor}.${parsed.patch}`; +} + +/** + * -1 if a < b, 0 if equal, 1 if a > b. Null when either side is not + * major.minor.patch (after an optional leading `v`). + */ +export function compareVersionStrings(a: string, b: string): number | null { + const left = parseVersionString(a); + const right = parseVersionString(b); + if (left === null || right === null) return null; + return compareVersions(left, right); +} + +function pathMarkers(probe: InstallProbe): string { + const resolved = probe.resolvedPath ?? probe.execPath; + return `${probe.execPath}\n${resolved}`.toLowerCase(); +} + +/** + * Best-effort install path detection. Prefer specific package managers over + * generic binary / source so upgrade hints match how the operator installed. + * + * Order matters: Bun-under-Homebrew (`/opt/homebrew/bin/bun`) is source, not + * homebrew — only the Cellar / formula binary is a brew install of Corbits. + */ +export function detectInstallMethod(probe: InstallProbe): InstallMethod { + const markers = pathMarkers(probe); + const exists = probe.pathExists ?? (() => false); + const env = probe.env ?? {}; + const execBase = basename(probe.execPath).toLowerCase(); + + // Definite Homebrew formula install (Cellar layout). + if ( + markers.includes("/cellar/corbits-code/") + || markers.includes("/cellar/corbits/") + ) { + return "homebrew"; + } + + // Running under the Bun runtime (clone + `bun run start` / `bun ./dist/...`). + // Checked before HOMEBREW_PREFIX so a brew-installed bun does not look like + // a brew-installed corbits. + if (execBase === "bun" || execBase === "bun.exe") { + return "source"; + } + const entry = probe.argv[1] ?? ""; + if ( + entry.endsWith(".ts") + || entry.endsWith(".tsx") + || entry.endsWith("/dist/index.js") + || entry.endsWith("\\dist\\index.js") + ) { + return "source"; + } + + // Debian package installs the binary at /usr/bin/corbits and drops dpkg metadata. + if (probe.platform === "linux") { + if ( + exists(`/var/lib/dpkg/info/${DEB_PACKAGE}.list`) + || exists(`/var/lib/dpkg/info/${DEB_PACKAGE}.md5sums`) + ) { + return "deb"; + } + if ( + (probe.execPath === `/usr/bin/${DEB_PACKAGE}` + || probe.resolvedPath === `/usr/bin/${DEB_PACKAGE}` + || (execBase === DEB_PACKAGE && markers.includes("/usr/bin/"))) + && exists(`/usr/share/doc/${DEB_PACKAGE}`) + ) { + return "deb"; + } + } + + // Homebrew symlink or prefix install of the corbits binary itself. + if (execBase === COMMAND_NAME || execBase === `${COMMAND_NAME}.exe`) { + const brewPrefix = env.HOMEBREW_PREFIX ?? env.HOMEBREW_CELLAR; + if ( + typeof brewPrefix === "string" + && brewPrefix.length > 0 + && markers.includes(brewPrefix.toLowerCase()) + ) { + return "homebrew"; + } + if (markers.includes("/homebrew/") || markers.includes("/linuxbrew/")) { + return "homebrew"; + } + } + + // Standalone compiled binary (GitHub release tarball or local `build:bin`). + if ( + execBase === COMMAND_NAME + || execBase === `${COMMAND_NAME}.exe` + ) { + return "binary"; + } + + return "unknown"; +} + +/** Method-specific upgrade guidance. Unknown never suggests brew or apt. */ +export function formatUpgradeMessage(input: { + readonly current: string; + readonly latest: string; + readonly method: InstallMethod; +}): string { + const head = `Upgrade available: ${PRODUCT_NAME} v${input.current} → v${input.latest}.`; + switch (input.method) { + case "homebrew": + return `${head} Run: brew update && brew upgrade ${BREW_FORMULA}`; + case "source": + return `${head} Pull latest, then: bun install && bun run start (or bun run build:bin)`; + case "deb": + return `${head} Download the .deb from ${RELEASES_URL}/latest and install with: sudo dpkg -i ${DEB_PACKAGE}_${input.latest}_*.deb`; + case "binary": + return `${head} Download the latest release: ${RELEASES_URL}/latest`; + case "unknown": + return `${head} See ${RELEASES_URL}`; + } +} + +/** + * Probe the GitHub releases API. Returns null on any failure (offline, + * rate-limit, malformed body, timeout) so callers can soft-skip. + */ +export async function fetchLatestReleaseVersion( + fetchImpl: typeof fetch = globalThis.fetch, + timeoutMs: number = UPGRADE_FETCH_TIMEOUT_MS, +): Promise { + try { + const res = await fetchImpl(RELEASES_LATEST_API, { + headers: { + accept: "application/vnd.github+json", + "user-agent": `${COMMAND_NAME}/${typeof pkg.version === "string" ? pkg.version : "0"}`, + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) return null; + const data: unknown = await res.json(); + if (data === null || typeof data !== "object") return null; + const tag = (data as { tag_name?: unknown }).tag_name; + if (typeof tag !== "string") return null; + return normalizeVersion(tag); + } catch { + return null; + } +} + +function defaultProbe(): InstallProbe { + return { + execPath: process.execPath, + argv: process.argv, + platform: process.platform, + pathExists: (p) => { + try { + return existsSync(p); + } catch { + return false; + } + }, + env: process.env, + }; +} + +/** + * Compare running version to the latest published release and, when behind, + * return a ready-to-surface notice. Never throws. + */ +export async function checkForUpgrade( + options: UpgradeCheckOptions = {}, +): Promise { + try { + const currentRaw = + options.currentVersion + ?? (typeof pkg.version === "string" ? pkg.version : "0.0.0"); + const current = normalizeVersion(currentRaw); + if (current === null) { + return { kind: "skipped", reason: "unparseable current version" }; + } + + const fetchLatest = + options.fetchLatest ?? (() => fetchLatestReleaseVersion()); + const latest = await fetchLatest(); + if (latest === null) { + return { kind: "skipped", reason: "latest version unavailable" }; + } + + const cmp = compareVersionStrings(latest, current); + if (cmp === null) { + return { kind: "skipped", reason: "unparseable latest version" }; + } + if (cmp <= 0) { + return { kind: "current" }; + } + + const method = + options.method + ?? detectInstallMethod(options.probe ?? defaultProbe()); + const message = formatUpgradeMessage({ current, latest, method }); + return { + kind: "available", + notice: { current, latest, method, message }, + }; + } catch { + return { kind: "skipped", reason: "upgrade check failed" }; + } +} + +/** + * Fire-and-forget startup probe: surface a notice when an upgrade is + * available; swallow every other outcome. Safe to `void` after host mount. + */ +export function scheduleUpgradeNotice(input: { + readonly notify: (text: string) => void; + readonly options?: UpgradeCheckOptions; +}): void { + void checkForUpgrade(input.options ?? {}) + .then((result) => { + if (result.kind === "available") { + input.notify(result.notice.message); + } + }) + .catch(() => { + // Soft fail: never reject into unhandledrejection. + }); +}