Files
kandev/apps/web/components/settings/system/updates-card.tsx
T
José Almeida 041a19f060 feat: service-managed UI self-update (#1210)
* 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
2026-06-01 17:49:15 +01:00

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>
);
}