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
8 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 −0
a561612eb0ad/tarry.dbbinary

binary file, not shown

view file at the parent

a561612eb0ad/tarry.db-shmbinary

binary file, not shown

view file at the parent

a561612eb0ad/tarry.db-walbinary

binary file, not shown

view file at the parent

package.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": {

view file

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;

view file

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+}

view file

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 }

view file

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

view file