diff --git a/.github/workflows/pr-commit-security-scan.yml b/.github/workflows/pr-commit-security-scan.yml index 584826d..ae1449d 100644 --- a/.github/workflows/pr-commit-security-scan.yml +++ b/.github/workflows/pr-commit-security-scan.yml @@ -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 '{}' )" diff --git a/bun.lock b/bun.lock index 96303e1..5d8ec4d 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index c0aa161..071b8e7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 3e004a6..6b0aba0 100755 --- a/src/index.ts +++ b/src/index.ts @@ -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 ", "Continue a saved session by id, or use 'latest'") .option("--background-task-file ", "Run a persisted background delegation") .option("--max-tool-rounds ", "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) { diff --git a/src/ui/app.tsx b/src/ui/app.tsx index b3468e1..98fb7c0 100644 --- a/src/ui/app.tsx +++ b/src/ui/app.tsx @@ -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(null); + const [showUpdateModal, setShowUpdateModal] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [updateOutput, setUpdateOutput] = useState(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) + {updateInfo?.hasUpdate && ( + + + {"┃ Update available: v"} + {startupConfig.version} + {" → v"} + {updateInfo.latestVersion} + {" — run /update to install"} + + + )} + {isUpdating && ( + + {"┃ Updating..."} + + )} + {updateOutput && !isUpdating && ( + + + {"┃ "} + {updateOutput} + + + )} {agent.getCwd().replace(os.homedir(), "~")} {sandboxMode === "shuru" ? {" · sandbox"} : null} - {"v1.0.0"} + {`v${startupConfig.version}`} )} @@ -3056,6 +3135,15 @@ export function App({ agent, startupConfig, initialMessage, onExit }: AppProps) onSubmit={submitApiKey} /> )} + {showUpdateModal && updateInfo && ( + + )} {showSlashMenu && ( + + + + {"Update Available"} + + {"esc to dismiss"} + + + + {"A new version of grok is available: "} + + {"v"} + {currentVersion} + + {" → "} + + {"v"} + {latestVersion} + + + + + {"Press enter to update now, or esc to dismiss"} + + + + ); +} + function SlashMenuModal({ t, selectedIndex, diff --git a/src/utils/update-checker.test.ts b/src/utils/update-checker.test.ts new file mode 100644 index 0000000..3736d32 --- /dev/null +++ b/src/utils/update-checker.test.ts @@ -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("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("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"); + }); +}); diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts new file mode 100644 index 0000000..9f137b4 --- /dev/null +++ b/src/utils/update-checker.ts @@ -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 { + 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 { + 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." }); + } + }); + }); +}