* feat(daemon): support direct download update for non-Homebrew installs Previously, CLI auto-update only worked for Homebrew installations. Non-brew binaries would fail with "not installed via Homebrew". Now the daemon and `multica update` fall back to downloading the release binary directly from GitHub Releases when Homebrew is not detected. Also fixes: - Daemon restart now uses the current executable's absolute path instead of searching PATH, ensuring the updated binary is used - Brew installs preserve the symlink path so the new Cellar version is picked up - Daemon startup logs now include the CLI version - Update UI auto-clears "restarting" status after 5s to show the new version Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(cli): remove dead DetectNewBinaryPath and guard against nil latest version Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
230 lines
6.4 KiB
TypeScript
230 lines
6.4 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import {
|
|
Loader2,
|
|
CheckCircle2,
|
|
XCircle,
|
|
ArrowUpCircle,
|
|
Check,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { api } from "@/shared/api";
|
|
import type { RuntimeUpdateStatus } from "@/shared/types";
|
|
|
|
const GITHUB_RELEASES_URL =
|
|
"https://api.github.com/repos/multica-ai/multica/releases/latest";
|
|
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
|
|
let cachedLatestVersion: string | null = null;
|
|
let cachedAt = 0;
|
|
|
|
async function fetchLatestVersion(): Promise<string | null> {
|
|
if (cachedLatestVersion && Date.now() - cachedAt < CACHE_TTL_MS) {
|
|
return cachedLatestVersion;
|
|
}
|
|
try {
|
|
const resp = await fetch(GITHUB_RELEASES_URL, {
|
|
headers: { Accept: "application/vnd.github+json" },
|
|
});
|
|
if (!resp.ok) return null;
|
|
const data = await resp.json();
|
|
cachedLatestVersion = data.tag_name ?? null;
|
|
cachedAt = Date.now();
|
|
return cachedLatestVersion;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function stripV(v: string): string {
|
|
return v.replace(/^v/, "");
|
|
}
|
|
|
|
function isNewer(latest: string, current: string): boolean {
|
|
const l = stripV(latest).split(".").map(Number);
|
|
const c = stripV(current).split(".").map(Number);
|
|
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
|
const lv = l[i] ?? 0;
|
|
const cv = c[i] ?? 0;
|
|
if (lv > cv) return true;
|
|
if (lv < cv) return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const statusConfig: Record<
|
|
RuntimeUpdateStatus,
|
|
{ label: string; icon: typeof Loader2; color: string }
|
|
> = {
|
|
pending: {
|
|
label: "Waiting for daemon...",
|
|
icon: Loader2,
|
|
color: "text-muted-foreground",
|
|
},
|
|
running: {
|
|
label: "Updating...",
|
|
icon: Loader2,
|
|
color: "text-info",
|
|
},
|
|
completed: {
|
|
label: "Update complete. Daemon is restarting...",
|
|
icon: CheckCircle2,
|
|
color: "text-success",
|
|
},
|
|
failed: { label: "Update failed", icon: XCircle, color: "text-destructive" },
|
|
timeout: { label: "Timeout", icon: XCircle, color: "text-warning" },
|
|
};
|
|
|
|
interface UpdateSectionProps {
|
|
runtimeId: string;
|
|
currentVersion: string | null;
|
|
isOnline: boolean;
|
|
}
|
|
|
|
export function UpdateSection({
|
|
runtimeId,
|
|
currentVersion,
|
|
isOnline,
|
|
}: UpdateSectionProps) {
|
|
const [latestVersion, setLatestVersion] = useState<string | null>(null);
|
|
const [status, setStatus] = useState<RuntimeUpdateStatus | null>(null);
|
|
const [error, setError] = useState("");
|
|
const [output, setOutput] = useState("");
|
|
const [updating, setUpdating] = useState(false);
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const cleanup = useCallback(() => {
|
|
if (pollRef.current) {
|
|
clearInterval(pollRef.current);
|
|
pollRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => cleanup, [cleanup]);
|
|
|
|
// Fetch latest version on mount.
|
|
useEffect(() => {
|
|
fetchLatestVersion().then(setLatestVersion);
|
|
}, []);
|
|
|
|
const handleUpdate = async () => {
|
|
if (!latestVersion) return;
|
|
cleanup();
|
|
setUpdating(true);
|
|
setStatus("pending");
|
|
setError("");
|
|
setOutput("");
|
|
|
|
try {
|
|
const update = await api.initiateUpdate(runtimeId, latestVersion);
|
|
|
|
pollRef.current = setInterval(async () => {
|
|
try {
|
|
const result = await api.getUpdateResult(runtimeId, update.id);
|
|
setStatus(result.status as RuntimeUpdateStatus);
|
|
|
|
if (result.status === "completed") {
|
|
setOutput(result.output ?? "");
|
|
setUpdating(false);
|
|
cleanup();
|
|
// Auto-clear status after a few seconds so the UI
|
|
// refreshes to show the new version from the re-fetched runtime data.
|
|
setTimeout(() => setStatus(null), 5000);
|
|
} else if (
|
|
result.status === "failed" ||
|
|
result.status === "timeout"
|
|
) {
|
|
setError(result.error ?? "Unknown error");
|
|
setUpdating(false);
|
|
cleanup();
|
|
}
|
|
} catch {
|
|
// ignore poll errors
|
|
}
|
|
}, 2000);
|
|
} catch {
|
|
setStatus("failed");
|
|
setError("Failed to initiate update");
|
|
setUpdating(false);
|
|
}
|
|
};
|
|
|
|
const hasUpdate =
|
|
currentVersion &&
|
|
latestVersion &&
|
|
isNewer(latestVersion, currentVersion);
|
|
|
|
const config = status ? statusConfig[status] : null;
|
|
const Icon = config?.icon;
|
|
const isActive = status === "pending" || status === "running";
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-xs text-muted-foreground">CLI Version:</span>
|
|
<span className="text-xs font-mono">
|
|
{currentVersion ?? "unknown"}
|
|
</span>
|
|
|
|
{!hasUpdate && currentVersion && latestVersion && !status && (
|
|
<span className="inline-flex items-center gap-1 text-xs text-success">
|
|
<Check className="h-3 w-3" />
|
|
Latest
|
|
</span>
|
|
)}
|
|
|
|
{hasUpdate && !status && (
|
|
<>
|
|
<span className="text-xs text-muted-foreground">→</span>
|
|
<span className="text-xs font-mono text-info">
|
|
{latestVersion}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">available</span>
|
|
</>
|
|
)}
|
|
|
|
{hasUpdate && isOnline && !status && (
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={handleUpdate}
|
|
disabled={updating}
|
|
>
|
|
<ArrowUpCircle className="h-3 w-3" />
|
|
Update
|
|
</Button>
|
|
)}
|
|
|
|
{config && Icon && (
|
|
<span
|
|
className={`inline-flex items-center gap-1 text-xs ${config.color}`}
|
|
>
|
|
<Icon className={`h-3 w-3 ${isActive ? "animate-spin" : ""}`} />
|
|
{config.label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{status === "completed" && output && (
|
|
<div className="rounded-lg border bg-success/5 px-3 py-2">
|
|
<p className="text-xs text-success">{output}</p>
|
|
</div>
|
|
)}
|
|
|
|
{(status === "failed" || status === "timeout") && error && (
|
|
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
|
|
<p className="text-xs text-destructive">{error}</p>
|
|
{status === "failed" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
className="mt-1"
|
|
onClick={handleUpdate}
|
|
>
|
|
Retry
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|