feat: add CLI update checker (#223)

* feat: add CLI update checker

Check for newer grok-dev releases when the interactive CLI starts and surface them through a startup modal, home banner, and update commands.

* ci: increase PR commit scan curl timeout to 300s
This commit is contained in:
Ismail Pelaseyed authored and GitHub committed 2026-03-26 14:59:06 +01:00
1 parent 231c63b4c8
commit 30c516de15
7 files changed
+421 -3

No files matched your search

@@ -42,7 +42,7 @@ jobs:
commit_url="https://github.com/${REPO}/commit/${sha}"
response="$(
curl -sfL --max-time 110 \
curl -sfL --max-time 300 \
"https://api.brin.sh/commit/${REPO}@${sha}?details=true&mode=full&tolerance=conservative" \
|| echo '{}'
)"
+6
View File
@@ -16,6 +16,7 @@
"dotenv": "^16.6.1",
"grammy": "^1.41.1",
"react": "^19.2.4",
"semver": "^7.7.4",
"zod": "^4.3.6",
},
"devDependencies": {
@@ -23,6 +24,7 @@
"@types/diff": "^8.0.0",
"@types/node": "^22.19.15",
"@types/react": "^19.2.14",
"@types/semver": "^7.7.1",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"typescript": "^5.9.3",
@@ -205,6 +207,8 @@
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="],
@@ -559,6 +563,8 @@
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
+2
View File
@@ -53,6 +53,7 @@
"dotenv": "^16.6.1",
"grammy": "^1.41.1",
"react": "^19.2.4",
"semver": "^7.7.4",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -60,6 +61,7 @@
"@types/diff": "^8.0.0",
"@types/node": "^22.19.15",
"@types/react": "^19.2.14",
"@types/semver": "^7.7.1",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"typescript": "^5.9.3",
+10
View File
@@ -25,6 +25,7 @@ import {
type SandboxSettings,
saveUserSettings,
} from "./utils/settings";
import { runUpdate } from "./utils/update-checker";
dotenv.config();
@@ -84,6 +85,7 @@ async function startInteractive(
maxToolRounds,
sandboxMode,
sandboxSettings,
version: packageJson.version,
},
initialMessage,
onExit,
@@ -267,7 +269,15 @@ program
.option("-s, --session <id>", "Continue a saved session by id, or use 'latest'")
.option("--background-task-file <path>", "Run a persisted background delegation")
.option("--max-tool-rounds <n>", "Max tool execution rounds", "400")
.option("--update", "Update grok to the latest version and exit")
.action(async (message: string[], options) => {
if (options.update) {
console.log("Checking for updates...");
const result = await runUpdate();
console.log(result.output);
process.exit(result.success ? 0 : 1);
}
changeDirectoryOrExit(options.directory);
if (options.backgroundTaskFile) {
+157 -2
View File
@@ -52,6 +52,7 @@ import {
saveUserSettings,
} from "../utils/settings";
import { discoverSkills, formatSkillsForChat } from "../utils/skills";
import { checkForUpdate, runUpdate, type UpdateCheckResult } from "../utils/update-checker";
import {
buildSubagentBrowseRows,
SUBAGENT_EDITOR_FIELDS,
@@ -291,6 +292,7 @@ const SLASH_MENU_ITEMS: SlashMenuItem[] = [
{ id: "commit-pr", label: "commit & pr", description: "Commit and open PR" },
{ id: "review", label: "review", description: "Review recent changes" },
{ id: "skills", label: "skills", description: "Manage skills" },
{ id: "update", label: "update", description: "Update grok to the latest version" },
];
const REVIEW_PROMPT = `Review all current changes in this repository. Follow these steps:
@@ -503,6 +505,7 @@ export interface AppStartupConfig {
sandboxMode: SandboxMode;
sandboxSettings: SandboxSettings;
maxToolRounds: number;
version: string;
}
interface AppProps {
@@ -646,6 +649,12 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
const [scheduleModalIndex, setScheduleModalIndex] = useState(0);
const showScheduleModalRef = useRef(false);
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [updateOutput, setUpdateOutput] = useState<string | null>(null);
const showUpdateModalRef = useRef(false);
const setMode = useCallback(
(m: AgentMode) => {
if (m === "agent" && mode === "plan" && activePlan) {
@@ -1653,6 +1662,21 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
useEffect(() => {
showScheduleModalRef.current = showScheduleModal;
}, [showScheduleModal]);
useEffect(() => {
showUpdateModalRef.current = showUpdateModal;
}, [showUpdateModal]);
useEffect(() => {
let cancelled = false;
checkForUpdate(startupConfig.version).then((result) => {
if (cancelled || !result?.hasUpdate) return;
setUpdateInfo(result);
setShowUpdateModal(true);
});
return () => {
cancelled = true;
};
}, [startupConfig.version]);
useEffect(() => {
return () => {
@@ -2103,6 +2127,18 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
case "commit-pr":
processMessage(COMMIT_PR_PROMPT);
break;
case "update":
setIsUpdating(true);
setUpdateOutput(null);
runUpdate().then((result) => {
setIsUpdating(false);
setUpdateOutput(
result.success
? "Update complete! Restart the CLI to use the new version."
: `Update failed: ${result.output}`,
);
});
break;
}
},
[
@@ -2125,7 +2161,8 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
showSandboxPicker ||
showScheduleModal ||
showAgentsModal ||
showAgentsEditor;
showAgentsEditor ||
showUpdateModal;
const showPlanPanel = !!activePlan?.questions?.length;
const planQuestions = activePlan?.questions ?? [];
@@ -2315,6 +2352,24 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
return;
}
if (showUpdateModalRef.current) {
if (isEscapeKey(key)) {
setShowUpdateModal(false);
return;
}
if (key.name === "return") {
setIsUpdating(true);
setShowUpdateModal(false);
runUpdate().then((result) => {
setIsUpdating(false);
setUpdateOutput(
result.success ? "Update complete! Restart the CLI to use the new version." : result.output,
);
});
return;
}
return;
}
if (showMcpEditorRef.current) {
if (isEscapeKey(key)) {
setShowMcpEditor(false);
@@ -3038,11 +3093,35 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
<box height={2} minHeight={0} flexShrink={1} />
<box flexGrow={1} minHeight={0} />
</box>
{updateInfo?.hasUpdate && (
<box paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0}>
<text fg="#f59e0b">
{"┃ Update available: v"}
{startupConfig.version}
{" → v"}
{updateInfo.latestVersion}
{" — run /update to install"}
</text>
</box>
)}
{isUpdating && (
<box paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0}>
<text fg="#f59e0b">{"┃ Updating..."}</text>
</box>
)}
{updateOutput && !isUpdating && (
<box paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0}>
<text fg={updateOutput.startsWith("Update complete") ? "#22c55e" : "#ef4444"}>
{"┃ "}
{updateOutput}
</text>
</box>
)}
<box paddingLeft={2} paddingRight={2} paddingBottom={1} flexDirection="row" flexShrink={0}>
<text fg={t.textDim}>{agent.getCwd().replace(os.homedir(), "~")}</text>
{sandboxMode === "shuru" ? <text fg="#f97316">{" · sandbox"}</text> : null}
<box flexGrow={1} />
<text fg={t.textDim}>{"v1.0.0"}</text>
<text fg={t.textDim}>{`v${startupConfig.version}`}</text>
</box>
</>
)}
@@ -3056,6 +3135,15 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps)
onSubmit={submitApiKey}
/>
)}
{showUpdateModal && updateInfo && (
<UpdateModal
t={t}
width={width}
height={height}
currentVersion={startupConfig.version}
latestVersion={updateInfo.latestVersion}
/>
)}
{showSlashMenu && (
<SlashMenuModal
t={t}
@@ -4140,6 +4228,73 @@ function bottomAlignedModalTop(height: number, panelHeight: number): number {
return Math.max(2, Math.floor((height - panelHeight) / 2));
}
/* ── Update Modal ────────────────────────────────────────────── */
function UpdateModal({
t,
width,
height,
currentVersion,
latestVersion,
}: {
t: Theme;
width: number;
height: number;
currentVersion: string;
latestVersion: string;
}) {
const overlayBg = "#000000cc" as string;
const panelWidth = Math.min(60, width - 6);
const panelHeight = 9;
const top = bottomAlignedModalTop(height, panelHeight);
return (
<box
position="absolute"
left={0}
top={0}
width={width}
height={height}
alignItems="center"
paddingTop={top}
backgroundColor={overlayBg}
>
<box
width={panelWidth}
height={panelHeight}
backgroundColor={t.backgroundPanel}
paddingTop={1}
paddingBottom={1}
flexDirection="column"
>
<box flexShrink={0} flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
<text fg="#f59e0b">
<b>{"Update Available"}</b>
</text>
<text fg={t.textMuted}>{"esc to dismiss"}</text>
</box>
<box flexShrink={0} paddingLeft={2} paddingRight={2} paddingTop={1}>
<text fg={t.text}>
{"A new version of grok is available: "}
<span style={{ fg: t.textMuted }}>
{"v"}
{currentVersion}
</span>
{" → "}
<span style={{ fg: "#22c55e" }}>
{"v"}
{latestVersion}
</span>
</text>
</box>
<box flexShrink={0} paddingLeft={2} paddingRight={2} paddingTop={1}>
<text fg={t.textMuted}>{"Press enter to update now, or esc to dismiss"}</text>
</box>
</box>
</box>
);
}
function SlashMenuModal({
t,
selectedIndex,
+186
View File
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const REGISTRY_URL = "https://registry.npmjs.org/grok-dev/latest";
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});
async function importModule() {
return import("./update-checker");
}
describe("checkForUpdate", () => {
it("returns hasUpdate=true when registry version is newer", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ version: "2.0.0" }),
});
vi.stubGlobal("fetch", mockFetch);
const { checkForUpdate } = await importModule();
const result = await checkForUpdate("1.0.0");
expect(result).not.toBeNull();
expect(result!.hasUpdate).toBe(true);
expect(result!.latestVersion).toBe("2.0.0");
expect(result!.currentVersion).toBe("1.0.0");
expect(mockFetch).toHaveBeenCalledWith(
REGISTRY_URL,
expect.objectContaining({ headers: { Accept: "application/json" } }),
);
});
it("returns hasUpdate=false when current version matches latest", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ version: "1.0.0" }),
}),
);
const { checkForUpdate } = await importModule();
const result = await checkForUpdate("1.0.0");
expect(result).not.toBeNull();
expect(result!.hasUpdate).toBe(false);
});
it("detects update from prerelease to stable release", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ version: "1.0.0" }),
}),
);
const { checkForUpdate } = await importModule();
const result = await checkForUpdate("1.0.0-rc7");
expect(result).not.toBeNull();
expect(result!.hasUpdate).toBe(true);
expect(result!.latestVersion).toBe("1.0.0");
expect(result!.currentVersion).toBe("1.0.0-rc7");
});
it("returns hasUpdate=false when prerelease is newer than registry", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ version: "0.9.0" }),
}),
);
const { checkForUpdate } = await importModule();
const result = await checkForUpdate("1.0.0-rc7");
expect(result).not.toBeNull();
expect(result!.hasUpdate).toBe(false);
});
it("returns null when the fetch fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network error")));
const { checkForUpdate } = await importModule();
const result = await checkForUpdate("1.0.0");
expect(result).toBeNull();
});
it("returns null when the registry returns a non-ok response", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 }));
const { checkForUpdate } = await importModule();
const result = await checkForUpdate("1.0.0");
expect(result).toBeNull();
});
it("returns null when the registry returns an invalid version", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ version: "not-a-version" }),
}),
);
const { checkForUpdate } = await importModule();
const result = await checkForUpdate("1.0.0");
expect(result).toBeNull();
});
it("returns null when the current version is invalid", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ version: "2.0.0" }),
}),
);
const { checkForUpdate } = await importModule();
const result = await checkForUpdate("garbage");
expect(result).toBeNull();
});
it("handles fetch timeout gracefully", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(() => new Promise((_, reject) => setTimeout(() => reject(new Error("aborted")), 10))),
);
const { checkForUpdate } = await importModule();
const result = await checkForUpdate("1.0.0");
expect(result).toBeNull();
});
});
describe("runUpdate", () => {
it("calls npm install and returns success on zero exit", async () => {
vi.doMock("child_process", async () => {
const actual = await vi.importActual<typeof import("child_process")>("child_process");
return {
...actual,
exec: (_cmd: string, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => {
cb(null, "added 1 package", "");
},
};
});
const { runUpdate } = await importModule();
const result = await runUpdate();
expect(result.success).toBe(true);
expect(result.output).toContain("added 1 package");
});
it("returns failure when the command errors", async () => {
vi.doMock("child_process", async () => {
const actual = await vi.importActual<typeof import("child_process")>("child_process");
return {
...actual,
exec: (_cmd: string, _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => {
cb(new Error("permission denied"), "", "permission denied");
},
};
});
const { runUpdate } = await importModule();
const result = await runUpdate();
expect(result.success).toBe(false);
expect(result.output).toContain("permission denied");
});
});
+59
View File
@@ -0,0 +1,59 @@
import { exec } from "child_process";
import semverGt from "semver/functions/gt.js";
import semverValid from "semver/functions/valid.js";
const PACKAGE_NAME = "grok-dev";
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
const FETCH_TIMEOUT_MS = 3_000;
export interface UpdateCheckResult {
currentVersion: string;
latestVersion: string;
hasUpdate: boolean;
}
export interface UpdateRunResult {
success: boolean;
output: string;
}
export async function checkForUpdate(currentVersion: string): Promise<UpdateCheckResult | null> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(REGISTRY_URL, {
signal: controller.signal,
headers: { Accept: "application/json" },
});
clearTimeout(timer);
if (!response.ok) return null;
const data = (await response.json()) as { version?: string };
const latestVersion = data.version;
if (!latestVersion || !semverValid(latestVersion)) return null;
const normalizedCurrent = semverValid(currentVersion);
if (!normalizedCurrent) return null;
const hasUpdate = semverGt(latestVersion, normalizedCurrent);
return { currentVersion: normalizedCurrent, latestVersion, hasUpdate };
} catch {
return null;
}
}
export function runUpdate(): Promise<UpdateRunResult> {
return new Promise((resolve) => {
const command = `npm install -g ${PACKAGE_NAME}@latest`;
exec(command, { timeout: 60_000 }, (error, stdout, stderr) => {
const output = (stdout || "").trim() + (stderr ? `\n${stderr.trim()}` : "");
if (error) {
resolve({ success: false, output: output || error.message });
} else {
resolve({ success: true, output: output || "Update complete." });
}
});
});
}