Skip to content

Commit c7fae9d

Browse files
Show install-specific upgrade guidance on TUI start (#445)
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.
1 parent 888863f commit c7fae9d

3 files changed

Lines changed: 570 additions & 0 deletions

File tree

src/tui/runner.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import { getTelemetry, liveTelemetry, setTelemetry } from "../telemetry/singleto
110110
import { createTelemetryToggleHandler } from "../telemetry/toggle.js";
111111

112112
import { loadStartupChangelogMarkdown } from "../changelog/index.js";
113+
import { scheduleUpgradeNotice } from "../upgrade/index.js";
113114
import pkg from "../../package.json" with { type: "json" };
114115
import { seedPricingMetadataFromCache } from "../cost/pricing-metadata.js";
115116
import { defaultPricingCachePath } from "../cost/pricing-fetcher.js";
@@ -2502,6 +2503,16 @@ export async function runTUI(initialConfig: Config): Promise<number> {
25022503
for (const notice of startupPluginNotices)
25032504
surfaceSystemNotice(host.shell, notice);
25042505

2506+
// Soft upgrade check: never blocks startup; offline / rate-limit is a quiet skip.
2507+
// surfaceSystemNotice keeps the landing hero up and flushes into the transcript
2508+
// once a session row ends the landing (same path as plugin/MCP startup chatter).
2509+
scheduleUpgradeNotice({
2510+
notify: (text) => surfaceSystemNotice(host.shell, text),
2511+
options: {
2512+
currentVersion: typeof pkg.version === "string" ? pkg.version : "0.0.0",
2513+
},
2514+
});
2515+
25052516
await host.waitUntilExit();
25062517
clearInterval(fleetStallPoll);
25072518
if (fleetSettle !== null) clearTimeout(fleetSettle);

src/upgrade/index.test.ts

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import {
4+
BREW_FORMULA,
5+
DEB_PACKAGE,
6+
RELEASES_URL,
7+
checkForUpgrade,
8+
compareVersionStrings,
9+
detectInstallMethod,
10+
formatUpgradeMessage,
11+
type InstallProbe,
12+
} from "./index.js";
13+
14+
function probe(partial: Partial<InstallProbe> & Pick<InstallProbe, "execPath">): InstallProbe {
15+
return {
16+
argv: [],
17+
platform: "darwin",
18+
pathExists: () => false,
19+
env: {},
20+
...partial,
21+
};
22+
}
23+
24+
describe("compareVersionStrings", () => {
25+
test("orders major.minor.patch and strips a leading v", () => {
26+
expect(compareVersionStrings("0.2.95", "0.2.94")).toBeGreaterThan(0);
27+
expect(compareVersionStrings("0.2.94", "0.2.95")).toBeLessThan(0);
28+
expect(compareVersionStrings("0.2.95", "0.2.95")).toBe(0);
29+
expect(compareVersionStrings("v0.3.0", "0.2.99")).toBeGreaterThan(0);
30+
});
31+
32+
test("returns null for unparseable input", () => {
33+
expect(compareVersionStrings("not-a-version", "0.1.0")).toBeNull();
34+
expect(compareVersionStrings("0.1.0", "")).toBeNull();
35+
});
36+
});
37+
38+
describe("detectInstallMethod", () => {
39+
test("detects Homebrew Cellar installs", () => {
40+
expect(
41+
detectInstallMethod(
42+
probe({
43+
execPath: "/opt/homebrew/Cellar/corbits-code/0.2.95/bin/corbits",
44+
}),
45+
),
46+
).toBe("homebrew");
47+
expect(
48+
detectInstallMethod(
49+
probe({
50+
execPath: "/usr/local/bin/corbits",
51+
resolvedPath: "/usr/local/Cellar/corbits-code/0.2.90/bin/corbits",
52+
}),
53+
),
54+
).toBe("homebrew");
55+
});
56+
57+
test("detects Homebrew via HOMEBREW_PREFIX when the binary lives under it", () => {
58+
expect(
59+
detectInstallMethod(
60+
probe({
61+
execPath: "/opt/homebrew/bin/corbits",
62+
env: { HOMEBREW_PREFIX: "/opt/homebrew" },
63+
}),
64+
),
65+
).toBe("homebrew");
66+
});
67+
68+
test("detects Debian package installs", () => {
69+
expect(
70+
detectInstallMethod(
71+
probe({
72+
execPath: "/usr/bin/corbits",
73+
platform: "linux",
74+
pathExists: (p) => p === `/var/lib/dpkg/info/${DEB_PACKAGE}.list`,
75+
}),
76+
),
77+
).toBe("deb");
78+
expect(
79+
detectInstallMethod(
80+
probe({
81+
execPath: "/usr/bin/corbits",
82+
platform: "linux",
83+
pathExists: (p) => p === `/usr/share/doc/${DEB_PACKAGE}`,
84+
}),
85+
),
86+
).toBe("deb");
87+
});
88+
89+
test("detects Bun / from-source runs", () => {
90+
expect(
91+
detectInstallMethod(
92+
probe({
93+
execPath: "/Users/dev/.bun/bin/bun",
94+
argv: ["bun", "/repo/corbits-code/src/index.ts"],
95+
}),
96+
),
97+
).toBe("source");
98+
expect(
99+
detectInstallMethod(
100+
probe({
101+
execPath: "/usr/local/bin/bun",
102+
argv: ["bun", "/repo/dist/index.js"],
103+
}),
104+
),
105+
).toBe("source");
106+
// brew-installed bun must not look like a brew-installed corbits
107+
expect(
108+
detectInstallMethod(
109+
probe({
110+
execPath: "/opt/homebrew/bin/bun",
111+
argv: ["bun", "/repo/src/index.ts"],
112+
env: { HOMEBREW_PREFIX: "/opt/homebrew" },
113+
}),
114+
),
115+
).toBe("source");
116+
});
117+
118+
test("detects standalone release binaries", () => {
119+
expect(
120+
detectInstallMethod(
121+
probe({
122+
execPath: "/home/user/.local/bin/corbits",
123+
platform: "linux",
124+
}),
125+
),
126+
).toBe("binary");
127+
expect(
128+
detectInstallMethod(
129+
probe({
130+
execPath: "/Users/dev/bin/corbits",
131+
platform: "darwin",
132+
}),
133+
),
134+
).toBe("binary");
135+
});
136+
137+
test("falls back to unknown rather than guessing brew", () => {
138+
expect(
139+
detectInstallMethod(
140+
probe({
141+
execPath: "/mysterious/path/agent-runner",
142+
argv: ["agent-runner"],
143+
}),
144+
),
145+
).toBe("unknown");
146+
});
147+
});
148+
149+
describe("formatUpgradeMessage", () => {
150+
const base = { current: "0.2.90", latest: "0.2.95" };
151+
152+
test("homebrew message uses the live formula upgrade", () => {
153+
const msg = formatUpgradeMessage({ ...base, method: "homebrew" });
154+
expect(msg).toContain("v0.2.90 → v0.2.95");
155+
expect(msg).toContain(`brew update && brew upgrade ${BREW_FORMULA}`);
156+
expect(msg).not.toContain("dpkg");
157+
});
158+
159+
test("source message points at pull + bun rebuild", () => {
160+
const msg = formatUpgradeMessage({ ...base, method: "source" });
161+
expect(msg).toContain("bun install");
162+
expect(msg).toContain("bun run start");
163+
expect(msg).not.toContain("brew upgrade");
164+
});
165+
166+
test("binary message points at the GitHub releases page", () => {
167+
const msg = formatUpgradeMessage({ ...base, method: "binary" });
168+
expect(msg).toContain(`${RELEASES_URL}/latest`);
169+
expect(msg).not.toContain("brew upgrade");
170+
expect(msg).not.toContain("dpkg");
171+
});
172+
173+
test("deb message points at dpkg install of the release artifact", () => {
174+
const msg = formatUpgradeMessage({ ...base, method: "deb" });
175+
expect(msg).toContain("dpkg -i");
176+
expect(msg).toContain(`${DEB_PACKAGE}_0.2.95_`);
177+
expect(msg).not.toContain("brew upgrade");
178+
});
179+
180+
test("unknown message is generic — no brew or apt command", () => {
181+
const msg = formatUpgradeMessage({ ...base, method: "unknown" });
182+
expect(msg).toContain(RELEASES_URL);
183+
expect(msg).not.toContain("brew");
184+
expect(msg).not.toContain("dpkg");
185+
expect(msg).not.toContain("apt");
186+
});
187+
});
188+
189+
describe("checkForUpgrade", () => {
190+
test("reports available when latest is newer", async () => {
191+
const result = await checkForUpgrade({
192+
currentVersion: "0.2.90",
193+
method: "homebrew",
194+
fetchLatest: async () => "0.2.95",
195+
});
196+
expect(result.kind).toBe("available");
197+
if (result.kind !== "available") return;
198+
expect(result.notice.current).toBe("0.2.90");
199+
expect(result.notice.latest).toBe("0.2.95");
200+
expect(result.notice.method).toBe("homebrew");
201+
expect(result.notice.message).toContain("brew upgrade");
202+
});
203+
204+
test("reports current when running the latest (or newer)", async () => {
205+
expect(
206+
(
207+
await checkForUpgrade({
208+
currentVersion: "0.2.95",
209+
fetchLatest: async () => "0.2.95",
210+
})
211+
).kind,
212+
).toBe("current");
213+
expect(
214+
(
215+
await checkForUpgrade({
216+
currentVersion: "0.3.0",
217+
fetchLatest: async () => "0.2.95",
218+
})
219+
).kind,
220+
).toBe("current");
221+
});
222+
223+
test("soft-skips when the network probe fails", async () => {
224+
const result = await checkForUpgrade({
225+
currentVersion: "0.2.90",
226+
fetchLatest: async () => null,
227+
});
228+
expect(result).toEqual({ kind: "skipped", reason: "latest version unavailable" });
229+
});
230+
231+
test("soft-skips when fetchLatest throws", async () => {
232+
const result = await checkForUpgrade({
233+
currentVersion: "0.2.90",
234+
fetchLatest: async () => {
235+
throw new Error("offline");
236+
},
237+
});
238+
expect(result.kind).toBe("skipped");
239+
});
240+
241+
test("detects method from probe when not forced", async () => {
242+
const result = await checkForUpgrade({
243+
currentVersion: "0.1.0",
244+
fetchLatest: async () => "0.2.0",
245+
probe: probe({
246+
execPath: "/Users/dev/.bun/bin/bun",
247+
argv: ["bun", "/repo/src/index.ts"],
248+
}),
249+
});
250+
expect(result.kind).toBe("available");
251+
if (result.kind !== "available") return;
252+
expect(result.notice.method).toBe("source");
253+
expect(result.notice.message).toContain("bun install");
254+
});
255+
});

0 commit comments

Comments
 (0)