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:
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 '{}'
|
||||
)"
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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." });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user