4622d4048bd6

add the builds panel with versions newest first, notes or their fallback, per-file label, size, sha-256, download count and install hints, and show the newest release in the repo header (v3.0.21)

time
2026-09-26 18:48 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
kepr 3 implementation
parent
a16dea674709
6 files package.json+1 −1public/css/kepr.css+58 −0src/pages/releases.ts+94 −0src/pages/repo.ts+4 −0src/server/app.ts+2 −0tests/releases.test.ts+117 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.20",3+	"version": "3.0.21",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+58 −0
@@ -1143,3 +1143,61 @@11431143 		animation: none;11441144 	}11451145 }1146+1147+/* ----- builds ------------------------------------------------------------- */1148+1149+.release {1150+	padding: 20px 8px 24px;1151+	border-bottom: 1px solid var(--border);1152+}1153+.release-head {1154+	display: flex;1155+	flex-wrap: wrap;1156+	align-items: baseline;1157+	gap: 0 14px;1158+	margin: 0 0 8px;1159+}1160+.release-version {1161+	font-family: "Martian Mono", monospace;1162+	font-size: 1rem;1163+	color: var(--accent);1164+}1165+.release-head .history-id:hover {1166+	color: var(--accent);1167+}1168+.release-notes {1169+	margin-bottom: 14px;1170+}1171+.release-file {1172+	padding: 10px 0;1173+	border-top: 1px dashed var(--border);1174+}1175+.release-file-head {1176+	display: flex;1177+	flex-wrap: wrap;1178+	align-items: baseline;1179+	gap: 4px 14px;1180+	margin: 0;1181+}1182+.release-platform {1183+	flex: 0 0 180px;1184+}1185+.release-download {1186+	flex: 1;1187+	font-size: 0.75rem;1188+	color: var(--accent);1189+	overflow-wrap: anywhere;1190+}1191+.release-meta,1192+.release-sha {1193+	font-size: 0.6875rem;1194+	color: var(--text-muted);1195+}1196+.release-sha {1197+	margin: 4px 0 0;1198+	overflow-wrap: anywhere;1199+}1200+.release-hint {1201+	margin: 4px 0 0;1202+	font-size: 0.8125rem;1203+}

view file

src/pages/releases.ts+94 −0
@@ -0,0 +1,94 @@1+import type { Hono } from "hono";2+import type { ReleaseFile } from "../bucket/repo.ts";3+import { saveByPrefix } from "../mirror/queries.ts";4+import { downloadCounts, type Release, visibleReleases } from "../releases/releases.ts";5+import { type Html, html } from "./html.ts";6+import {7+	cacheFor,8+	formatDate,9+	formatSize,10+	notFound,11+	openPublicRepo,12+	type PageContext,13+	type PublicRepo,14+	page,15+	plural,16+} from "./layout.ts";17+import { renderMarkdown } from "./markdown.ts";18+19+// §11.1's labels.20+const PLATFORM_LABELS: Record<string, string> = {21+	"linux-x86_64": "Linux · x86_64",22+	"linux-aarch64": "Linux · ARM64",23+	"macos-aarch64": "macOS · Apple Silicon",24+	"macos-x86_64": "macOS · Intel",25+	"windows-x86_64": "Windows · x86_64",26+	"android-arm64": "Android · ARM64",27+	"android-x86": "Android · x86_64",28+};29+30+/** §21.9's builds panel: every visible release, newest first. */31+export function registerReleasesPage(app: Hono, ctx: PageContext): void {32+	app.get("/:repo/releases", (c) => {33+		const repo = openPublicRepo(ctx, c.req.param("repo"));34+		if (!repo) return notFound(c, ctx);35+		const name = repo.settings.name;36+		const releases = visibleReleases(ctx.kepr, repo.mirror, name);37+		const counts = downloadCounts(ctx.kepr, name);38+		cacheFor(c, false);39+		return c.html(40+			page(ctx, {41+				title: `builds · ${name}`,42+				description: `Releases of ${name}, with downloads and checksums.`,43+				path: `/${name}/releases`,44+				body: html`<p class="breadcrumb mono"><a href="/${name}">${name}</a></p>45+<h1 class="file-page-title">builds</h1>46+<section class="repo-section">47+${releases.length > 0 ? releases.map((release) => releaseBlock(repo, release, counts)) : html`<p class="muted mono save-empty">no releases yet</p>`}48+</section>`,49+			}),50+		);51+	});52+}53+54+function releaseBlock(repo: PublicRepo, release: Release, counts: Map<string, number>): Html {55+	const name = repo.settings.name;56+	const save = saveByPrefix(repo.mirror, release.save);57+	// A checkpoint has no page of its own (§21.4), and a save the mirror lacks has none either.58+	const saveLink =59+		save && !save.is_checkpoint60+			? html`<a class="history-id" href="/${name}/saves/${save.id}">${save.id}</a>`61+			: html`<span class="history-id">${release.save}</span>`;62+	// §11.2: empty notes show the release save's message, else "Release <v> of <repo>."63+	const notes =64+		release.notes !== ""65+			? renderMarkdown(release.notes, { repo: name, dir: "", at: null })66+			: html`<p>${save?.message ?? `Release ${release.version} of ${name}.`}</p>`;67+	return html`<article class="release" id="${release.version}">68+<p class="release-head"><span class="release-version">v${release.version}</span>${saveLink}<span class="history-when">${formatDate(release.publishedAt)}</span></p>69+<div class="release-notes markdown">${notes}</div>70+<div class="release-files">71+${release.files.map((file) => fileRow(name, release.version, file, counts.get(`${release.version}/${file.platform}`) ?? 0))}72+</div>73+</article>`;74+}75+76+function fileRow(repo: string, version: string, file: ReleaseFile, downloads: number): Html {77+	const hint = installHint(file.name);78+	// The `<repo>-<platform>` form, as Kepr 2 linked it, so `curl -O` saves a self-describing name.79+	return html`<div class="release-file">80+<p class="release-file-head"><span class="release-platform">${PLATFORM_LABELS[file.platform]}</span><a class="release-download mono" href="/${repo}/releases/${version}/${repo}-${file.platform}">↓ ${file.name}</a><span class="release-meta mono">${formatSize(file.size)} · ${plural(downloads, "download")}</span></p>81+<p class="release-sha mono"><span class="muted">sha256</span> ${file.sha256}</p>82+${hint && html`<p class="release-hint muted">${hint}</p>`}83+</div>`;84+}85+86+// Kepr 2's install hints, with the wording of §21.9.87+function installHint(fileName: string): string {88+	const lower = fileName.toLowerCase();89+	if (lower.endsWith(".app.zip")) return "unzip and drag to Applications";90+	if (lower.endsWith(".apk")) {91+		return "open on your device to install — allow installs from this source if prompted";92+	}93+	return "";94+}

view file

src/pages/repo.ts+4 −0
@@ -10,6 +10,7 @@1010 	saveByPrefix,1111 	timeline,1212 } from "../mirror/queries.ts";13+import { visibleReleases } from "../releases/releases.ts";1314 import { setting } from "../server/db.ts";1415 import { atlasSection } from "./atlas.ts";1516 import { type Html, html } from "./html.ts";@@ -70,7 +71,10 @@7071 	const { name, homepage, installLines } = repo.settings;7172 	const stats = repoStats(repo.mirror);7273 	const handle = setting(ctx.kepr, "handle");74+	// §21.4: the version is the newest release; with none, the header shows no version.75+	const newest = visibleReleases(ctx.kepr, repo.mirror, name)[0];7376 	const meta = [77+		newest && `v${newest.version}`,7478 		plural(stats.saves, "save"),7579 		stats.lines > 0 && plural(stats.lines, "line"),7680 		stats.updated !== null && `updated ${relativeTime(stats.updated)}`,

view file

src/server/app.ts+2 −0
@@ -6,6 +6,7 @@66 import { isSafePath } from "../bucket/repo.ts";77 import { registerArchivesPage } from "../pages/archives.ts";88 import { registerFileRoutes } from "../pages/files.ts";9+import { registerReleasesPage } from "../pages/releases.ts";910 import { registerRepoPage } from "../pages/repo.ts";1011 import { registerSavePage } from "../pages/save.ts";1112 import { registerTarballRoutes } from "../tarball/archive.ts";@@ -53,6 +54,7 @@5354 	registerFileRoutes(app, ctx);5455 	registerSavePage(app, ctx);5556 	registerArchivesPage(app, ctx);57+	registerReleasesPage(app, ctx);5658 	registerTarballRoutes(app, ctx, join(config.dataDir, "tarballs"));5759 	return app;5860 }

view file

tests/releases.test.ts+117 −0
@@ -1,11 +1,14 @@1+import type { Database } from "bun:sqlite";12 import { beforeAll, describe, expect, test } from "bun:test";23 import { mkdtemp, writeFile } from "node:fs/promises";34 import { tmpdir } from "node:os";45 import { join } from "node:path";6+import type { Hono } from "hono";57 import { openBucket } from "../src/bucket/reader.ts";68 import { type SyncTarget, syncAll } from "../src/bucket/sync.ts";79 import { openMirror } from "../src/mirror/store.ts";810 import { compareVersions, visibleReleases } from "../src/releases/releases.ts";11+import { createApp } from "../src/server/app.ts";912 import { openKeprDb } from "../src/server/db.ts";1013 import { makeRepos, writeRelease } from "./fixture.ts";1114 @@ -100,3 +103,117 @@100103 	const sorted = ["1.10.0", "1.9.9", "2", "1.9", "0.0.0", "1.10", "10.0.0"].sort(compareVersions);101104 	expect(sorted).toEqual(["0.0.0", "1.9", "1.9.9", "1.10", "1.10.0", "2", "10.0.0"]);102105 });106+107+describe("the builds panel", () => {108+	let app: Hono;109+	let saves: Record<string, string>;110+	const get = (path: string) => app.request(path);111+112+	beforeAll(async () => {113+		const repos = await makeRepos(["tool", "secret"]);114+		saves = repos.saves;115+		const tool = saves.tool as string;116+		const b = repos.bucketDir;117+		await writeRelease(b, "tool", {118+			version: "0.9.0",119+			save: tool,120+			notes: "## Fixes\n\n- the *first* fix <script>",121+			files: [{ platform: "linux-x86_64", name: "tool", bytes: bytes("old") }],122+		});123+		await writeRelease(b, "tool", {124+			version: "0.10.0",125+			save: tool,126+			publishedAt: 1790100000,127+			files: [128+				{ platform: "macos-aarch64", name: "Tool.app.zip", bytes: bytes("mac") },129+				{ platform: "android-arm64", name: "tool-0.10.0.apk", bytes: bytes("apk") },130+			],131+		});132+		await writeRelease(b, "tool", {133+			version: "0.11.0",134+			save: "aaaaaaaaaaaa",135+			files: [{ platform: "linux-x86_64", name: "tool", bytes: bytes("new") }],136+		});137+		await writeRelease(b, "secret", {138+			version: "1.0.0",139+			save: saves.secret as string,140+			files: [{ platform: "linux-x86_64", name: "s", bytes: bytes("s") }],141+		});142+		app = await releaseApp(b, (kepr) => {143+			kepr.run("UPDATE repos SET public = 1 WHERE name = 'tool'");144+			kepr.run(145+				"INSERT INTO downloads (repo, version, platform, count) VALUES ('tool', '0.10.0', 'android-arm64', 7)",146+			);147+		});148+	});149+150+	test("lists versions newest first with their save, date, notes and files", async () => {151+		const res = await get("/tool/releases");152+		expect(res.status).toBe(200);153+		const body = await res.text();154+		const order = ["v0.11.0", "v0.10.0", "v0.9.0"].map((v) => body.indexOf(`>${v}<`));155+		expect(order).toEqual([...order].sort((x, y) => x - y));156+		expect(order[0]).toBeGreaterThan(0);157+		expect(body).toContain(`href="/tool/saves/${saves.tool}"`);158+		expect(body).toContain("2026-09-22 18:00");159+		expect(body).toContain("<h2>Fixes</h2>");160+		expect(body).toContain("&lt;script&gt;");161+		// Empty notes: the save's message, or Kepr 2's line when the save is unknown.162+		expect(body).toContain("<p>release tool</p>");163+		expect(body).toContain("<p>Release 0.11.0 of tool.</p>");164+	});165+166+	test("each file shows its label, size, SHA-256, download count and install hint", async () => {167+		const body = await (await get("/tool/releases")).text();168+		expect(body).toContain("Android · ARM64");169+		expect(body).toContain("macOS · Apple Silicon");170+		expect(body).toContain('href="/tool/releases/0.10.0/tool-android-arm64"');171+		expect(body).toContain("3 B · 7 downloads");172+		expect(body).toContain(new Bun.CryptoHasher("sha256").update("apk").digest("hex"));173+		expect(body).toContain("unzip and drag to Applications");174+		expect(body).toContain(175+			"open on your device to install — allow installs from this source if prompted",176+		);177+	});178+179+	test("hidden versions don't show, and the repo header names the newest visible release", async () => {180+		expect(await (await get("/tool")).text()).toContain("v0.11.0 · ");181+		const kepr = releaseKepr.get(app);182+		kepr?.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('tool', '0.11.0', 1)");183+		const body = await (await get("/tool/releases")).text();184+		expect(body).not.toContain("0.11.0");185+		expect(await (await get("/tool")).text()).toContain("v0.10.0 · ");186+		kepr?.run("DELETE FROM release_settings");187+	});188+189+	test("a private repo's builds are a 404", async () => {190+		expect((await get("/secret/releases")).status).toBe(404);191+	});192+});193+194+const releaseKepr = new WeakMap<Hono, Database>();195+196+/** Syncs a folder remote into a fresh data dir and serves it as kepr.example. */197+async function releaseApp(bucketDir: string, setup: (kepr: Database) => void): Promise<Hono> {198+	const dataDir = await mkdtemp(join(tmpdir(), "kepr-release-app-"));199+	const kepr = openKeprDb(dataDir);200+	const bucket = openBucket({ kind: "folder", path: bucketDir });201+	await syncAll({202+		bucket,203+		kepr,204+		mirrorDir: join(dataDir, "mirror"),205+		tarballDir: join(dataDir, "tarballs"),206+	});207+	setup(kepr);208+	const app = createApp(209+		{210+			domain: "kepr.example",211+			dataDir,212+			adminPasswordHash: "$argon2id$x",213+			bucket: { kind: "folder", path: bucketDir },214+		},215+		kepr,216+	);217+	releaseKepr.set(app, kepr);218+	return app;219+}

view file