Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -2502,6 +2503,16 @@ export async function runTUI(initialConfig: Config): Promise<number> {
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);
Expand Down
255 changes: 255 additions & 0 deletions src/upgrade/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<InstallProbe> & Pick<InstallProbe, "execPath">): 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");
});
});
Loading
Loading