Skip to content

Commit ed7b621

Browse files
chore: added update check
1 parent b8ad1e3 commit ed7b621

File tree

2 files changed

+189
-1
lines changed

2 files changed

+189
-1
lines changed

src/components/UpdateCheckGate.tsx

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { spawn } from "node:child_process";
2+
3+
import { Box, Text, useApp, useInput } from "ink";
4+
import React, { useCallback, useEffect, useState } from "react";
5+
6+
import { version as currentVersion } from "../../package.json";
7+
8+
import Header from "./Header.js";
9+
10+
type Phase = "checking" | "prompt" | "updating" | "done" | "error";
11+
12+
interface UpdateCheckGateProps {
13+
children?: React.ReactNode;
14+
}
15+
16+
function compareSemver(a: string, b: string): number {
17+
const aParts = a.split(".").map((p) => parseInt(p, 10));
18+
const bParts = b.split(".").map((p) => parseInt(p, 10));
19+
const len = Math.max(aParts.length, bParts.length);
20+
for (let i = 0; i < len; i += 1) {
21+
const av = Number.isFinite(aParts[i]) ? aParts[i] : 0;
22+
const bv = Number.isFinite(bParts[i]) ? bParts[i] : 0;
23+
if (av > bv) return 1;
24+
if (av < bv) return -1;
25+
}
26+
return 0;
27+
}
28+
29+
async function fetchLatestVersion(pkgName: string): Promise<string | null> {
30+
try {
31+
// Prefer the lightweight /latest endpoint
32+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkgName)}/latest`, {
33+
headers: { Accept: "application/vnd.npm.install-v1+json" },
34+
});
35+
if (!res.ok) return null;
36+
const data = (await res.json()) as { version?: string };
37+
return typeof data.version === "string" ? data.version : null;
38+
} catch {
39+
return null;
40+
}
41+
}
42+
43+
const UpdateCheckGate: React.FC<UpdateCheckGateProps> = ({ children }) => {
44+
const { exit } = useApp();
45+
const [phase, setPhase] = useState<Phase>("checking");
46+
const [latestVersion, setLatestVersion] = useState<string | null>(null);
47+
const [messages, setMessages] = useState<string[]>([]);
48+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
49+
50+
useEffect(() => {
51+
let cancelled = false;
52+
(async () => {
53+
const latest = await fetchLatestVersion("finagent");
54+
if (cancelled) return;
55+
if (!latest) {
56+
setPhase("done");
57+
return;
58+
}
59+
setLatestVersion(latest);
60+
const cmp = compareSemver(currentVersion, latest);
61+
if (cmp >= 0) {
62+
setPhase("done");
63+
} else {
64+
setPhase("prompt");
65+
}
66+
})().catch(() => setPhase("done"));
67+
return () => {
68+
cancelled = true;
69+
};
70+
}, []);
71+
72+
const runUpdate = useCallback(async () => {
73+
setPhase("updating");
74+
setMessages(["Updating finagent with npm ..."]);
75+
await new Promise<void>((resolve) => {
76+
const child = spawn("npm", ["install", "-g", "finagent@latest"], {
77+
stdio: ["ignore", "pipe", "pipe"],
78+
});
79+
child.stdout.on("data", (d) => {
80+
const s = String(d);
81+
for (const line of s.split(/\r?\n/)) {
82+
if (!line) continue;
83+
setMessages((prev) => [...prev, line]);
84+
}
85+
});
86+
child.stderr.on("data", (d) => {
87+
const s = String(d);
88+
for (const line of s.split(/\r?\n/)) {
89+
if (!line) continue;
90+
setMessages((prev) => [...prev, line]);
91+
}
92+
});
93+
child.on("close", (code) => {
94+
if (code === 0) {
95+
setMessages((prev) => [
96+
...prev,
97+
"✅ finagent updated. Please restart the app to use the new version.",
98+
]);
99+
// Exit to encourage restart into the new version
100+
exit();
101+
} else {
102+
setErrorMessage(`npm exited with code ${code ?? "unknown"}`);
103+
setPhase("error");
104+
}
105+
resolve();
106+
});
107+
child.on("error", (err) => {
108+
setErrorMessage(err instanceof Error ? err.message : String(err));
109+
setPhase("error");
110+
resolve();
111+
});
112+
});
113+
}, [exit]);
114+
115+
useInput((input, key) => {
116+
if (phase === "prompt") {
117+
if (input?.toLowerCase() === "y") {
118+
void runUpdate();
119+
} else if (input?.toLowerCase() === "n" || key.escape) {
120+
setPhase("done");
121+
}
122+
}
123+
if (phase === "error") {
124+
if (key.escape || (key.ctrl && input === "c")) {
125+
setPhase("done");
126+
}
127+
}
128+
});
129+
130+
if (phase === "done") {
131+
return <>{children}</>;
132+
}
133+
134+
if (phase === "updating") {
135+
return (
136+
<>
137+
<Header />
138+
<Box flexDirection="column">
139+
{messages.map((m, idx) => (
140+
// eslint-disable-next-line react/no-array-index-key
141+
<Text key={idx}>{m}</Text>
142+
))}
143+
<Text dimColor>Updating... This may take a minute.</Text>
144+
</Box>
145+
</>
146+
);
147+
}
148+
149+
if (phase === "error") {
150+
return (
151+
<>
152+
<Header />
153+
<Box flexDirection="column">
154+
<Text color="red">Failed to update finagent via npm.</Text>
155+
{errorMessage ? <Text color="red">{errorMessage}</Text> : null}
156+
<Text dimColor>Press Esc to continue without updating.</Text>
157+
</Box>
158+
</>
159+
);
160+
}
161+
162+
// checking or prompt
163+
return (
164+
<>
165+
<Header />
166+
<Box flexDirection="column">
167+
{phase === "checking" ? (
168+
<Text dimColor>Checking for updates...</Text>
169+
) : (
170+
<>
171+
<Text>
172+
A newer version of finagent is available: v{currentVersion} → v{latestVersion}
173+
</Text>
174+
<Text>Update now? (y/n)</Text>
175+
</>
176+
)}
177+
</Box>
178+
</>
179+
);
180+
};
181+
182+
export default UpdateCheckGate;

src/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import { render } from "ink";
44
import React from "react";
55

66
import App from "./App.js";
7+
import UpdateCheckGate from "./components/UpdateCheckGate.js";
78
import { ensureAnthropicApiKeyEnvFromConfig } from "./services/config.js";
89

910
ensureAnthropicApiKeyEnvFromConfig();
1011

11-
render(<App />, { exitOnCtrlC: false });
12+
render(
13+
<UpdateCheckGate>
14+
<App />
15+
</UpdateCheckGate>,
16+
{ exitOnCtrlC: false },
17+
);

0 commit comments

Comments
 (0)