041a19f060
* feat: add service-managed self-update * chore: add temporary self-update test scripts * chore: simplify temporary script markdown fences * chore: run frontend in temporary self-update scripts * fix: show self-update job progress after apply * chore: add real npm self-update test script * fix: isolate npm self-update prefix * fix: detect npm service installs from bin shim * chore: reset service before real npm update test * chore: expand real npm status diagnostics * fix: preserve service home during self-update * fix(cli): tee self-update helper output to a log file * fix(cli): avoid launchd bootstrap race when reloading the macOS service * fix(backend): run macOS self-update helper once via transient launchd plist * fix(web): show self-update progress and lock the apply button until the update lands * fix(cli): reinstall via the kandev wrapper on Homebrew self-update * chore: remove temp self-update test scripts * fix(web): drop unused apply path from useUpdates hook * fix(backend): snapshot install state once in apply preflight * docs: align self-update docs with transient LaunchAgent flow * chore(release): run cli bundle from apps workspace root * fix(backend): guard against concurrent self-update applies * fix(backend): check scheme and helper paths in self-update apply * fix(backend): drop duplicate binary name from npx manual command * fix(cli): allow --help for service self-update without --intent * fix(cli): harden self-update dir perms and darwin uid resolution * fix(web): set required POST fields last in applyUpdate * docs: use JSON body notation for the apply endpoint * fix(backend): expire self-update guard so a stuck helper cannot wedge apply * refactor(cli): collapse redundant writeSync branch in self-update log
311 lines
8.9 KiB
TypeScript
311 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from "@kandev/ui/alert-dialog";
|
|
import { Badge } from "@kandev/ui/badge";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
|
import { Spinner } from "@kandev/ui/spinner";
|
|
import { IconDownload, IconExternalLink, IconRefresh } from "@tabler/icons-react";
|
|
import { useSelfUpdate } from "@/hooks/domains/system/use-self-update";
|
|
import { useUpdates } from "@/hooks/domains/system/use-updates";
|
|
import type { UpdatesResponse } from "@/lib/types/system";
|
|
import { SelfUpdateProgress } from "./self-update-progress";
|
|
|
|
interface ApplyGate {
|
|
canApply: boolean;
|
|
cannotApplyReason?: string;
|
|
manualCommands: string[];
|
|
}
|
|
|
|
function formatChecked(iso: string | undefined): string {
|
|
if (!iso) return "never";
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return iso;
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
function errorMessage(err: unknown, fallback: string): string {
|
|
return err instanceof Error ? err.message : fallback;
|
|
}
|
|
|
|
function retryAfterSeconds(message: string): number | null {
|
|
const match = /retry.*?(\d+)/i.exec(message);
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
function getApplyGate(updates: UpdatesResponse | null | undefined): ApplyGate {
|
|
const install = updates?.install;
|
|
const available = updates?.update_available === true;
|
|
const canApply =
|
|
available &&
|
|
install?.running_as_service === true &&
|
|
install.managed_service === true &&
|
|
updates?.apply_supported === true;
|
|
return {
|
|
canApply,
|
|
cannotApplyReason: updates?.apply_unsupported_reason,
|
|
manualCommands: updates?.manual_commands ?? [],
|
|
};
|
|
}
|
|
|
|
export function UpdatesCard() {
|
|
const { updates, check, reload } = useUpdates();
|
|
const selfUpdate = useSelfUpdate({ latestVersion: updates?.latest, onComplete: reload });
|
|
const [checking, setChecking] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [retryAfter, setRetryAfter] = useState<number | null>(null);
|
|
|
|
const onCheck = async () => {
|
|
setChecking(true);
|
|
setError(null);
|
|
setRetryAfter(null);
|
|
try {
|
|
await check();
|
|
} catch (err) {
|
|
const message = errorMessage(err, "Update check failed");
|
|
setError(message);
|
|
setRetryAfter(retryAfterSeconds(message));
|
|
} finally {
|
|
setChecking(false);
|
|
}
|
|
};
|
|
|
|
const current = updates?.current ?? "-";
|
|
const latest = updates?.latest ?? "-";
|
|
const available = updates?.update_available === true;
|
|
const gate = getApplyGate(updates);
|
|
// Hide the Apply button while an update is in flight (and once it's done —
|
|
// the version has flipped) so it can't be re-triggered mid-restart.
|
|
const showApply = gate.canApply && !selfUpdate.isUpdating && selfUpdate.phase !== "done";
|
|
|
|
return (
|
|
<Card data-testid="system-updates-card">
|
|
<CardHeader>
|
|
<UpdatesHeader available={available} />
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<VersionGrid current={current} latest={latest} />
|
|
<LastChecked checkedAt={updates?.latest_checked_at} />
|
|
<UpdateActions
|
|
checking={checking}
|
|
showApply={showApply}
|
|
latest={latest}
|
|
url={updates?.latest_url}
|
|
onCheck={onCheck}
|
|
onApply={selfUpdate.start}
|
|
/>
|
|
<ManualUpdateInstructions
|
|
show={available && !gate.canApply && !selfUpdate.isUpdating}
|
|
reason={gate.cannotApplyReason}
|
|
commands={gate.manualCommands}
|
|
/>
|
|
<SelfUpdateProgress
|
|
phase={selfUpdate.phase}
|
|
targetVersion={selfUpdate.targetVersion}
|
|
errorMessage={selfUpdate.errorMessage}
|
|
onDismiss={selfUpdate.dismiss}
|
|
/>
|
|
<UpdateError error={error} retryAfter={retryAfter} />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function UpdatesHeader({ available }: { available: boolean }) {
|
|
return (
|
|
<CardTitle className="text-base flex items-center gap-2">
|
|
<IconRefresh className="h-4 w-4" />
|
|
Updates
|
|
{available && (
|
|
<Badge variant="default" className="text-[10px]" data-testid="system-updates-badge">
|
|
Update available
|
|
</Badge>
|
|
)}
|
|
</CardTitle>
|
|
);
|
|
}
|
|
|
|
function VersionGrid({ current, latest }: { current: string; latest: string }) {
|
|
return (
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<VersionValue label="Current version" value={current} testId="system-updates-current" />
|
|
<VersionValue label="Latest release" value={latest} testId="system-updates-latest" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function VersionValue({ label, value, testId }: { label: string; value: string; testId: string }) {
|
|
return (
|
|
<div>
|
|
<div className="text-xs text-muted-foreground">{label}</div>
|
|
<div className="font-mono text-sm" data-testid={testId}>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LastChecked({ checkedAt }: { checkedAt?: string }) {
|
|
return (
|
|
<div className="text-xs text-muted-foreground" data-testid="system-updates-checked-at">
|
|
Last checked {formatChecked(checkedAt)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface UpdateActionsProps {
|
|
checking: boolean;
|
|
showApply: boolean;
|
|
latest: string;
|
|
url?: string;
|
|
onCheck: () => Promise<void>;
|
|
onApply: () => Promise<void>;
|
|
}
|
|
|
|
function UpdateActions(props: UpdateActionsProps) {
|
|
return (
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
|
<CheckNowButton checking={props.checking} onCheck={props.onCheck} />
|
|
<ReleaseNotesLink url={props.url} />
|
|
<ApplyUpdateDialog
|
|
showApply={props.showApply}
|
|
latest={props.latest}
|
|
onApply={props.onApply}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CheckNowButton({
|
|
checking,
|
|
onCheck,
|
|
}: {
|
|
checking: boolean;
|
|
onCheck: () => Promise<void>;
|
|
}) {
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={checking}
|
|
onClick={() => void onCheck()}
|
|
className="cursor-pointer"
|
|
data-testid="system-updates-check"
|
|
>
|
|
{checking ? (
|
|
<Spinner className="size-3.5 mr-1" />
|
|
) : (
|
|
<IconRefresh className="h-3.5 w-3.5 mr-1" />
|
|
)}
|
|
Check now
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function ReleaseNotesLink({ url }: { url?: string }) {
|
|
if (!url) return null;
|
|
return (
|
|
<Button
|
|
asChild
|
|
variant="ghost"
|
|
size="sm"
|
|
className="cursor-pointer"
|
|
data-testid="system-updates-release-link"
|
|
>
|
|
<a href={url} target="_blank" rel="noreferrer">
|
|
Release notes
|
|
<IconExternalLink className="h-3.5 w-3.5 ml-1" />
|
|
</a>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
interface ApplyUpdateDialogProps {
|
|
showApply: boolean;
|
|
latest: string;
|
|
onApply: () => Promise<void>;
|
|
}
|
|
|
|
function ApplyUpdateDialog({ showApply, latest, onApply }: ApplyUpdateDialogProps) {
|
|
if (!showApply) return null;
|
|
return (
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button size="sm" className="cursor-pointer" data-testid="system-updates-apply">
|
|
<IconDownload className="h-3.5 w-3.5 mr-1" />
|
|
Apply update
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Apply update?</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-left">
|
|
Kandev will update to {latest}, reinstall the user service, and restart it.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className="cursor-pointer"
|
|
onClick={() => void onApply()}
|
|
data-testid="system-updates-apply-confirm"
|
|
>
|
|
Apply update
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
);
|
|
}
|
|
|
|
function ManualUpdateInstructions({
|
|
show,
|
|
reason,
|
|
commands,
|
|
}: {
|
|
show: boolean;
|
|
reason?: string;
|
|
commands: string[];
|
|
}) {
|
|
if (!show || !reason) return null;
|
|
return (
|
|
<div className="space-y-2 text-xs text-muted-foreground" data-testid="system-updates-manual">
|
|
<p>{reason}</p>
|
|
<ManualCommands commands={commands} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ManualCommands({ commands }: { commands: string[] }) {
|
|
if (commands.length === 0) return null;
|
|
return (
|
|
<div className="space-y-1">
|
|
{commands.map((cmd) => (
|
|
<code key={cmd} className="block break-all rounded bg-muted px-2 py-1 font-mono">
|
|
{cmd}
|
|
</code>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UpdateError({ error, retryAfter }: { error: string | null; retryAfter: number | null }) {
|
|
if (!error) return null;
|
|
return (
|
|
<p className="text-xs text-destructive" data-testid="system-updates-error">
|
|
{retryAfter ? `Already checked. Try again in ${retryAfter}s.` : error}
|
|
</p>
|
|
);
|
|
}
|