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
↓ kepr-4622d404.tar.gzatlas at this save
files changed6 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 −0package.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": {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+}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+}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)}`,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 }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("<script>");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+}