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:
14
src/app/(dashboard)/settings/updates/page.tsx
Normal file
14
src/app/(dashboard)/settings/updates/page.tsx
Normal 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 />;
|
||||
}
|
||||
42
src/app/api/account/self-update/route.ts
Normal file
42
src/app/api/account/self-update/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
151
src/components/settings/self-update-panel.tsx
Normal file
151
src/components/settings/self-update-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user