v0.8.0 — Update-available detection (version chip + changelog)
Footer becomes a changelog chip and shows an 'Update available' badge when a newer APP_VERSION exists upstream. Detection reads src/lib/version.ts from the Gitea default branch (dms model) and compares — self-contained, no OTM control plane. Adds src/lib/update-check.ts and GET /api/version (also a running-version probe for the upcoming one-click rebuild). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
19
src/app/api/version/route.ts
Normal file
19
src/app/api/version/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
import { getUpdateStatus } from "@/lib/update-check";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Running-version probe + upstream update check. Used by the footer chip, and
|
||||||
|
// (later) polled by the self-update UI to detect when the rebuilt instance is
|
||||||
|
// back online on the new version.
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAuth();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(await getUpdateStatus(), {
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,12 +1,83 @@
|
|||||||
import Link from "next/link";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
import { CHANGELOG } from "@/lib/changelog";
|
||||||
|
import type { UpdateStatus } from "@/lib/update-check";
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [status, setStatus] = useState<UpdateStatus | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetch("/api/version")
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((d) => {
|
||||||
|
if (!cancelled) setStatus(d);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateAvailable = status?.updateAvailable && status.latest;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="h-8 border-t flex items-center justify-end px-4 md:px-6 shrink-0">
|
<>
|
||||||
<span className="text-[11px] text-muted-foreground/60">
|
<footer className="h-8 border-t flex items-center justify-end gap-2 px-4 md:px-6 shrink-0">
|
||||||
Moon Base v{APP_VERSION}
|
{updateAvailable && (
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-medium rounded-full px-2 py-0.5 bg-amber-100 text-amber-800"
|
||||||
|
title={`Version ${status!.latest} is available`}
|
||||||
|
>
|
||||||
|
Update available → v{status!.latest}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="text-[11px] text-muted-foreground/60 hover:text-foreground transition-colors tabular-nums"
|
||||||
|
title="Show changelog"
|
||||||
|
>
|
||||||
|
Moon Base v{APP_VERSION}
|
||||||
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Changelog</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{updateAvailable && (
|
||||||
|
<p className="text-sm rounded-md px-3 py-2 bg-amber-50 text-amber-800">
|
||||||
|
A newer version (v{status!.latest}) is available. You're on v{APP_VERSION}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-5">
|
||||||
|
{CHANGELOG.map((entry) => (
|
||||||
|
<div key={entry.version} className="space-y-1.5">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="font-semibold text-sm tabular-nums">v{entry.version}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{entry.date}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="text-sm space-y-1 list-disc pl-5 marker:text-muted-foreground">
|
||||||
|
{entry.changes.map((c, i) => (
|
||||||
|
<li key={i}>{c}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.8.0",
|
||||||
|
date: "2026-06-15",
|
||||||
|
changes: [
|
||||||
|
"The app now tells you when a newer version is available — a little \"Update available\" badge appears in the bottom bar, and the version number opens the full changelog. (One-click updating is coming next.)",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.7.0",
|
version: "0.7.0",
|
||||||
date: "2026-06-15",
|
date: "2026-06-15",
|
||||||
|
|||||||
60
src/lib/update-check.ts
Normal file
60
src/lib/update-check.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { APP_VERSION } from "./version";
|
||||||
|
|
||||||
|
// Detects whether a newer version has been pushed upstream by reading
|
||||||
|
// src/lib/version.ts from the Gitea repo's default branch and comparing its
|
||||||
|
// APP_VERSION to the running one. Self-contained — no OTM control plane needed.
|
||||||
|
// Reuses the same GITEA_* env as the suggestions module.
|
||||||
|
const GITEA_URL = process.env.GITEA_URL;
|
||||||
|
const GITEA_TOKEN = process.env.GITEA_TOKEN;
|
||||||
|
const GITEA_REPO = process.env.GITEA_REPO;
|
||||||
|
const GITEA_BRANCH = process.env.GITEA_BRANCH || "master";
|
||||||
|
|
||||||
|
export type UpdateStatus = {
|
||||||
|
version: string;
|
||||||
|
latest: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read APP_VERSION from src/lib/version.ts on the upstream branch. null on any failure. */
|
||||||
|
export async function fetchUpstreamVersion(): Promise<string | null> {
|
||||||
|
if (!GITEA_URL || !GITEA_TOKEN || !GITEA_REPO) return null;
|
||||||
|
try {
|
||||||
|
const url = `${GITEA_URL}/api/v1/repos/${GITEA_REPO}/contents/src/lib/version.ts?ref=${GITEA_BRANCH}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `token ${GITEA_TOKEN}` },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const json = await res.json();
|
||||||
|
if (!json?.content) return null;
|
||||||
|
const decoded = Buffer.from(
|
||||||
|
json.content,
|
||||||
|
json.encoding === "base64" ? "base64" : "utf8"
|
||||||
|
).toString("utf8");
|
||||||
|
const m = decoded.match(/APP_VERSION\s*=\s*["']([^"']+)["']/);
|
||||||
|
return m ? m[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numeric dotted compare. -1 if a<b, 0 if equal, 1 if a>b. */
|
||||||
|
export function compareVersions(a: string, b: string): number {
|
||||||
|
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
||||||
|
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
||||||
|
const len = Math.max(pa.length, pb.length);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const d = (pa[i] || 0) - (pb[i] || 0);
|
||||||
|
if (d !== 0) return d > 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUpdateStatus(): Promise<UpdateStatus> {
|
||||||
|
const latest = await fetchUpstreamVersion();
|
||||||
|
return {
|
||||||
|
version: APP_VERSION,
|
||||||
|
latest,
|
||||||
|
updateAvailable: latest != null && compareVersions(latest, APP_VERSION) > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||||
export const APP_VERSION = "0.7.0";
|
export const APP_VERSION = "0.8.0";
|
||||||
|
|||||||
Reference in New Issue
Block a user