bf8f698c551b
add the archives panel with HEAD and recent tarballs and nix profile add lines for flakes, drop stray test mirror files, and record phase 3 in continuity (v3.0.16)
- time
- 2026-09-26 18:33 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- kepr 3 implementation
- parent
- af1f70ffc57a
↓ kepr-bf8f698c.tar.gzatlas at this save
files changed8 files
a561612eb0ad/tarry.dbbinarya561612eb0ad/tarry.db-shmbinarya561612eb0ad/tarry.db-walbinarypackage.json+1 −1public/css/kepr.css+45 −0src/pages/archives.ts+57 −0src/server/app.ts+2 −0tests/tarball.test.ts+30 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.15",3+ "version": "3.0.16",44 "private": true,55 "type": "module",66 "scripts": {public/css/kepr.css+45 −0
@@ -1028,6 +1028,47 @@10281028 color: #c45c5c;10291029 }10301030 1031+/* ----- archives ----------------------------------------------------------- */1032+1033+.archive-row {1034+ padding: 12px 8px;1035+ border-bottom: 1px solid var(--border);1036+}1037+.archive-save {1038+ display: flex;1039+ flex-wrap: wrap;1040+ align-items: baseline;1041+ gap: 0 10px;1042+ margin: 0;1043+}1044+.archive-save .history-id {1045+ flex: 0 0 110px;1046+}1047+.archive-msg {1048+ flex: 1;1049+}1050+.archive-head {1051+ font-size: 0.625rem;1052+ letter-spacing: 0.08em;1053+ color: var(--accent);1054+ border: 1px solid var(--accent-dim);1055+ border-radius: 2px;1056+ padding: 0 6px;1057+}1058+.archive-save .history-id:hover {1059+ color: var(--accent);1060+}1061+.archive-links {1062+ margin: 6px 0;1063+ font-size: 0.75rem;1064+}1065+.archive-links a {1066+ color: var(--accent);1067+}1068+.archive-row .install-line {1069+ justify-content: space-between;1070+}1071+10311072 /* ----- phone -------------------------------------------------------------- */10321073 10331074 @media (max-width: 768px) {@@ -1072,6 +1113,10 @@10721113 }10731114 .save-message-head {10741115 font-size: 1.625rem;1116+ }1117+ .archive-msg {1118+ flex-basis: 100%;1119+ order: 1;10751120 }10761121 .diff .dl {10771122 grid-template-columns: 2.75em 2.75em 1.25em 1fr;src/pages/archives.ts+57 −0
@@ -0,0 +1,57 @@1+import type { Hono } from "hono";2+import { fileAt, headId, history, type Save, saveByPrefix } from "../mirror/queries.ts";3+import { type Html, html } from "./html.ts";4+import {5+ cacheFor,6+ notFound,7+ openPublicRepo,8+ type PageContext,9+ type PublicRepo,10+ page,11+ relativeTime,12+} from "./layout.ts";13+14+const RECENT = 20;15+16+export function registerArchivesPage(app: Hono, ctx: PageContext): void {17+ app.get("/:repo/archives", (c) => {18+ const repo = openPublicRepo(ctx, c.req.param("repo"));19+ if (!repo) return notFound(c, ctx);20+ const name = repo.settings.name;21+ const id = headId(repo.mirror);22+ const head = id ? saveByPrefix(repo.mirror, id) : null;23+ const recent = history(repo.mirror, RECENT + 1, 0).filter((save) => save.id !== head?.id);24+ cacheFor(c, false);25+ return c.html(26+ page(ctx, {27+ title: `archives · ${name}`,28+ description: `Source tarballs of ${name}, one per save.`,29+ path: `/${name}/archives`,30+ body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>31+<h1 class="file-page-title">archives</h1>32+<section class="repo-section">33+${head ? [row(ctx, repo, head, true), ...recent.slice(0, RECENT).map((save) => row(ctx, repo, save, false))] : html`<p class="muted mono save-empty">no saves yet</p>`}34+</section>`,35+ }),36+ );37+ });38+}39+40+// HEAD's row names HEAD.tar.gz, which always follows the newest save; the others pin their save.41+// The `nix profile add` line shows only where the save has a flake to install, as in Kepr 2.42+function row(ctx: PageContext, repo: PublicRepo, save: Save, isHead: boolean): Html {43+ const name = repo.settings.name;44+ const file = isHead ? "HEAD" : save.id;45+ const tarball = `/${name}/archive/${file}.tar.gz`;46+ const nix = `nix profile add https://${ctx.domain}${tarball}`;47+ const hasFlake = fileAt(repo.mirror, save.id, "flake.nix") !== null;48+ // HEAD can be a checkpoint, which has no page of its own (§21.4).49+ const id = save.is_checkpoint50+ ? html`<span class="history-id">${save.id}</span>`51+ : html`<a class="history-id" href="/${name}/saves/${save.id}">${save.id}</a>`;52+ return html`<div class="archive-row">53+<p class="archive-save">${isHead && html`<span class="archive-head mono">HEAD</span>`}${id}<span class="history-msg archive-msg">${save.message.split("\n")[0]}</span><span class="history-when">${relativeTime(save.created_at)}</span></p>54+<p class="archive-links mono"><a href="${tarball}">↓ ${name}-${isHead ? "HEAD" : save.id.slice(0, 8)}.tar.gz</a></p>55+${hasFlake && html`<div class="install-line"><code>${nix}</code><button type="button" class="copy-btn" data-copy="${nix}" hidden>copy</button></div>`}56+</div>`;57+}src/server/app.ts+2 −0
@@ -4,6 +4,7 @@44 import { bodyLimit } from "hono/body-limit";55 import { secureHeaders } from "hono/secure-headers";66 import { isSafePath } from "../bucket/repo.ts";7+import { registerArchivesPage } from "../pages/archives.ts";78 import { registerFileRoutes } from "../pages/files.ts";89 import { registerRepoPage } from "../pages/repo.ts";910 import { registerSavePage } from "../pages/save.ts";@@ -51,6 +52,7 @@5152 registerRepoPage(app, ctx);5253 registerFileRoutes(app, ctx);5354 registerSavePage(app, ctx);55+ registerArchivesPage(app, ctx);5456 registerTarballRoutes(app, ctx, join(config.dataDir, "tarballs"));5557 return app;5658 }tests/tarball.test.ts+30 −0
@@ -247,3 +247,33 @@247247 expect(listed).toContain("src/main.ts");248248 });249249 });250+251+describe("the archives panel", () => {252+ test("lists HEAD and recent saves with tarball links, and nix lines where there is a flake", async () => {253+ const response = await get("/tarry/archives");254+ expect(response.status).toBe(200);255+ expect(response.headers.get("cache-control")).toBe("public, max-age=60");256+ const page = await response.text();257+ const ids = [...page.matchAll(/class="history-id"[^>]*>([0-9a-f]{12})</g)].map((m) => m[1]);258+ expect(ids).toEqual([head, fx.saveIds[0], fx.saveIds[2], fx.saveIds[3]]);259+ expect(page).toContain('<span class="archive-head mono">HEAD</span>');260+ expect(page).toContain('href="/tarry/archive/HEAD.tar.gz">↓ tarry-HEAD.tar.gz</a>');261+ expect(page).toContain(262+ 'data-copy="nix profile add https://kepr.example/tarry/archive/HEAD.tar.gz"',263+ );264+ // Only HEAD has flake.nix; older saves get a tarball link but no nix line.265+ expect(page.match(/nix profile add/g)?.length).toBe(2);266+ const older = fx.saveIds[0] as string;267+ expect(page).toContain(268+ `href="/tarry/archive/${older}.tar.gz">↓ tarry-${older.slice(0, 8)}.tar.gz</a>`,269+ );270+ expect(page).not.toContain(fx.saveIds[1] as string);271+ });272+273+ test("a private repo's panel is a 404", async () => {274+ target.kepr.run("UPDATE repos SET public = 0");275+ const status = (await get("/tarry/archives")).status;276+ target.kepr.run("UPDATE repos SET public = 1");277+ expect(status).toBe(404);278+ });279+});