v0.9.0 — One-click self-update (homegrown, no OTM)

Add an admin 'Pull & rebuild' button (Settings → Updates). It POSTs an
admin-gated route that signals a privileged 'updater' sidecar (adnanh/webhook +
docker socket) over the internal network with a shared secret; the sidecar runs
'git fetch && reset --hard origin/master && docker compose up -d --build app'
detached. The UI polls /api/version until the rebuilt app returns on the new
version, then reloads.

Secret matching uses webhook -template + getenv so UPDATER_SECRET stays out of
the repo (host .env only). No request data reaches the shell — the command is
fixed in updater/update.sh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 22:20:48 -05:00
parent 42cd7a7b4d
commit 1c66dbd481
11 changed files with 293 additions and 2 deletions

View File

@@ -0,0 +1,14 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { getSession, isAdmin } from "@/lib/auth";
import { SelfUpdatePanel } from "@/components/settings/self-update-panel";
export const metadata: Metadata = { title: "Updates" };
export const dynamic = "force-dynamic";
export default async function UpdatesPage() {
const session = await getSession();
if (!session) redirect("/login");
if (!isAdmin(session.user.role)) redirect("/dashboard");
return <SelfUpdatePanel />;
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { getSession, isAdmin } from "@/lib/auth";
export const dynamic = "force-dynamic";
// Admin-only trigger for the homegrown self-updater. Signals the privileged
// `updater` sidecar (which holds the docker socket) over the internal network
// with a shared secret; the sidecar runs `git pull && docker compose up -d
// --build app` detached. No user input reaches the shell — the command is fixed
// in updater/update.sh. The browser then polls /api/version for the new version.
export async function POST() {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const url = process.env.UPDATER_URL;
const secret = process.env.UPDATER_SECRET;
if (!url || !secret) {
return NextResponse.json({ error: "not_configured" }, { status: 503 });
}
try {
const res = await fetch(url, {
method: "POST",
headers: { "X-Update-Token": secret },
cache: "no-store",
});
if (!res.ok) {
return NextResponse.json(
{ error: `updater responded ${res.status}` },
{ status: 502 }
);
}
return NextResponse.json({ started: true });
} catch {
return NextResponse.json({ error: "updater unreachable" }, { status: 502 });
}
}

View File

@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb } from "lucide-react";
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
@@ -17,6 +17,7 @@ const navItems = [
const settingsItems = [
{ label: "Suggestions", href: "/suggestions", icon: Lightbulb },
{ label: "Backup & Restore", href: "/settings/backup", icon: HardDrive },
{ label: "Updates", href: "/settings/updates", icon: RefreshCw },
];
export function Sidebar() {

View File

@@ -0,0 +1,151 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { RefreshCw, CheckCircle2, AlertTriangle, Loader2 } from "lucide-react";
type Status = { version: string; latest: string | null; updateAvailable: boolean };
type Phase = "idle" | "starting" | "rebuilding" | "done" | "error";
export function SelfUpdatePanel() {
const [status, setStatus] = useState<Status | null>(null);
const [phase, setPhase] = useState<Phase>("idle");
const [message, setMessage] = useState("");
const startVersion = useRef<string | null>(null);
const poll = useRef<ReturnType<typeof setInterval> | null>(null);
async function loadStatus(): Promise<Status | null> {
try {
const r = await fetch("/api/version", { cache: "no-store" });
if (!r.ok) return null;
const d = (await r.json()) as Status;
setStatus(d);
return d;
} catch {
return null;
}
}
useEffect(() => {
loadStatus();
return () => {
if (poll.current) clearInterval(poll.current);
};
}, []);
async function triggerUpdate() {
setPhase("starting");
setMessage("Starting update…");
startVersion.current = status?.version ?? null;
try {
const r = await fetch("/api/account/self-update", { method: "POST" });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
setPhase("error");
setMessage(
j.error === "not_configured"
? "The updater isn't set up on this server yet."
: `Couldn't start the update (${r.status}).`
);
return;
}
} catch {
setPhase("error");
setMessage("Couldn't reach the server to start the update.");
return;
}
setPhase("rebuilding");
setMessage(
"Pulling the latest version and rebuilding. This can take a couple of minutes — don't close this page."
);
let tries = 0;
poll.current = setInterval(async () => {
tries++;
const d = await loadStatus();
if (d && startVersion.current && d.version !== startVersion.current) {
if (poll.current) clearInterval(poll.current);
setPhase("done");
setMessage(`Updated to v${d.version}. Reloading…`);
setTimeout(() => window.location.reload(), 2000);
} else if (tries > 90) {
// ~6 minutes with no version change
if (poll.current) clearInterval(poll.current);
setPhase("error");
setMessage(
"The update is taking longer than expected. Check the server, then reload this page."
);
}
}, 4000);
}
const busy = phase === "starting" || phase === "rebuilding";
const updateAvailable = !!status?.updateAvailable && !!status.latest;
return (
<div className="max-w-xl space-y-4">
<div>
<h1 className="text-2xl font-display font-semibold tracking-tight">Updates</h1>
<p className="text-sm text-muted-foreground">
Pull the latest version of Moon Base and rebuild the app.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
Current version
<span className="tabular-nums font-normal text-muted-foreground">
v{status?.version ?? "…"}
</span>
{updateAvailable ? (
<Badge className="bg-amber-100 text-amber-800 hover:bg-amber-100">
Update available v{status!.latest}
</Badge>
) : status ? (
<Badge className="bg-emerald-100 text-emerald-800 hover:bg-emerald-100">
Up to date
</Badge>
) : null}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{phase !== "idle" && (
<div
className={
"flex items-start gap-2 rounded-md px-3 py-2 text-sm " +
(phase === "error"
? "bg-red-50 text-red-800"
: phase === "done"
? "bg-emerald-50 text-emerald-800"
: "bg-amber-50 text-amber-800")
}
>
{phase === "error" ? (
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
) : phase === "done" ? (
<CheckCircle2 className="h-4 w-4 mt-0.5 shrink-0" />
) : (
<Loader2 className="h-4 w-4 mt-0.5 shrink-0 animate-spin" />
)}
<span>{message}</span>
</div>
)}
<Button onClick={triggerUpdate} disabled={busy} className="gap-2">
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{updateAvailable ? `Update to v${status!.latest}` : "Pull & rebuild"}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -8,6 +8,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.9.0",
date: "2026-06-15",
changes: [
"One-click updates — admins get a new Updates page under Settings with a \"Pull & rebuild\" button. When a new version is available, click it and the app updates itself and reloads. No more waiting on Tony.",
],
},
{
version: "0.8.0",
date: "2026-06-15",

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
export const APP_VERSION = "0.8.0";
export const APP_VERSION = "0.9.0";