a79b2f329d95
serve the generic install.sh per repo, tested end to end against a stand-in s3, and add robots.txt, the public-repos sitemap and kepr 2's favicon (v3.0.24)
- time
- 2026-09-26 18:56 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- kepr 3 implementation
- parent
- 075f6d740acc
↓ kepr-a79b2f32.tar.gzatlas at this save
files changed10 files
package.json+1 −1public/favicon.svg+5 −0src/pages/housekeeping.ts+40 −0src/pages/layout.ts+1 −0src/releases/install.sh+49 −0src/releases/routes.ts+17 −0src/server/app.ts+2 −0src/server/db.ts+9 −0tests/install.test.ts+122 −0tests/pages.test.ts+25 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.23",3+ "version": "3.0.24",44 "private": true,55 "type": "module",66 "scripts": {public/favicon.svg+5 −0
@@ -0,0 +1,5 @@1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">2+ <title>kepr</title>3+ <polygon points="16,3 27,23 5,23" fill="none" stroke="#c89a5a" stroke-width="2" stroke-linejoin="round"/>4+ <polygon points="16,10 22,21 10,21" fill="none" stroke="#c89a5a" stroke-width="1.5" stroke-linejoin="round"/>5+</svg>\ No newline at end of filesrc/pages/housekeeping.ts+40 −0
@@ -0,0 +1,40 @@1+import { readFileSync } from "node:fs";2+import { join } from "node:path";3+import type { Hono } from "hono";4+import { publicRepoNames } from "../server/db.ts";5+import type { PageContext } from "./layout.ts";6+7+// Kepr 2's mark, in Kepr's gold.8+const FAVICON = readFileSync(join(import.meta.dir, "../../public/favicon.svg"));9+10+/** §22.6: robots.txt, the sitemap (public repos only, as everywhere, §20) and the favicon. */11+export function registerHousekeeping(app: Hono, ctx: PageContext): void {12+ const origin = `https://${ctx.domain}`;13+ app.get("/robots.txt", (c) =>14+ c.body(`User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`, 200, {15+ "Content-Type": "text/plain; charset=UTF-8",16+ }),17+ );18+ app.get("/sitemap.xml", (c) => {19+ const url = (path: string, changefreq: string, priority: string) =>20+ ` <url><loc>${origin}${path}</loc><changefreq>${changefreq}</changefreq><priority>${priority}</priority></url>\n`;21+ const urls = [22+ url("/", "daily", "1.0"),23+ url("/browse", "daily", "0.9"),24+ url("/docs", "weekly", "0.8"),25+ // Repo names are [A-Za-z0-9._-] (§9.2's check at sync), so they need no XML escaping.26+ ...publicRepoNames(ctx.kepr).map((name) => url(`/${name}`, "daily", "0.7")),27+ ];28+ return c.body(29+ `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join("")}</urlset>\n`,30+ 200,31+ { "Content-Type": "application/xml; charset=UTF-8", "Cache-Control": "public, max-age=3600" },32+ );33+ });34+ app.get("/favicon.svg", (c) =>35+ c.body(FAVICON, 200, {36+ "Content-Type": "image/svg+xml",37+ "Cache-Control": "public, max-age=86400",38+ }),39+ );40+}src/pages/layout.ts+1 −0
@@ -55,6 +55,7 @@5555 <meta name="twitter:description" content="${options.description}">5656 <meta name="theme-color" content="#090706">5757 <link rel="canonical" href="${url}">58+<link rel="icon" href="/favicon.svg" type="image/svg+xml">5859 <link rel="preconnect" href="https://fonts.bunny.net">5960 <link rel="stylesheet" href="${FONTS}">6061 <link rel="stylesheet" href="/static/css/kepr.css">src/releases/install.sh+49 −0
@@ -0,0 +1,49 @@1+#!/bin/sh2+# Installs the newest release of @REPO@ for this machine, checked against its SHA-256 (§22.5).3+# curl -fsSL @BASE@/install.sh | sh4+# INSTALL_DIR picks the folder (default ~/.local/bin).5+set -eu6+7+repo=@REPO@8+base=@BASE@/releases9+10+fail() {11+ echo "$repo: $*" >&212+ exit 113+}14+15+case "$(uname -s)" in16+Linux) os=linux ;;17+Darwin) os=macos ;;18+*) fail "no builds for $(uname -s)" ;;19+esac20+case "$(uname -m)" in21+x86_64 | amd64) arch=x86_64 ;;22+arm64 | aarch64) arch=aarch64 ;;23+*) fail "no builds for $(uname -m)" ;;24+esac25+platform=$os-$arch26+27+command -v curl >/dev/null 2>&1 || fail "installing needs curl"28+if command -v sha256sum >/dev/null 2>&1; then29+ sha256() { sha256sum "$1" | cut -d ' ' -f 1; }30+elif command -v shasum >/dev/null 2>&1; then31+ sha256() { shasum -a 256 "$1" | cut -d ' ' -f 1; }32+else33+ fail "installing needs sha256sum or shasum"34+fi35+36+version=$(curl -fsSL "$base/latest/version.txt") || fail "no release found"37+expected=$(curl -fsSL "$base/$version/checksums.txt" |38+ awk -v name="$repo-$platform" '$2 == name && sub(/^sha256:/, "", $1) { print $1 }')39+[ -n "$expected" ] || fail "$version has no build for $platform"40+41+dir=${INSTALL_DIR:-$HOME/.local/bin}42+mkdir -p "$dir"43+tmp=$(mktemp "$dir/.$repo.XXXXXX")44+trap 'rm -f "$tmp"' EXIT45+curl -fsSL -o "$tmp" "$base/latest/$platform" || fail "download failed"46+[ "$(sha256 "$tmp")" = "$expected" ] || fail "checksum mismatch; nothing installed"47+chmod 755 "$tmp"48+mv "$tmp" "$dir/$repo"49+echo "$repo $version installed to $dir/$repo"src/releases/routes.ts+17 −0
@@ -1,3 +1,5 @@1+import { readFileSync } from "node:fs";2+import { join } from "node:path";13 import type { Context, Hono } from "hono";24 import type { BucketReader } from "../bucket/reader.ts";35 import { isPlatform, isVersion, type ReleaseFile } from "../bucket/repo.ts";@@ -21,6 +23,9 @@2123 */2224 const FROZEN_KOH2 = { repo: "koh", version: "2.1.16" } as const;2325 26+// §22.5: one generic script; each repo gets its own name and address filled in.27+const INSTALL_SH = readFileSync(join(import.meta.dir, "install.sh"), "utf8");28+2429 const JSON_TYPE = "application/json; charset=UTF-8";2530 const TEXT_TYPE = "text/plain; charset=UTF-8";2631 // Kepr 2's error bodies. A private repo answers exactly as an unknown one (§20).@@ -127,6 +132,18 @@127132 const version = c.req.param("version");128133 if (!isVersion(version)) return send(c, 404, RELEASE_NOT_FOUND);129134 return downloadAt(c, c.req.param("repo"), version, c.req.param("platform"));135+ });136+ // Only for a repo with a release, since the script would have nothing to install.137+ app.get("/:repo/install.sh", (c) => {138+ const name = c.req.param("repo");139+ const release = newest(c, name);140+ if (release instanceof Response) return release;141+ const script = INSTALL_SH.replaceAll("@REPO@", name).replaceAll(142+ "@BASE@",143+ `https://${ctx.domain}/${name}`,144+ );145+ c.header("Cache-Control", "public, max-age=60");146+ return send(c, 200, script, TEXT_TYPE);130147 });131148 app.get("/:repo/update-info", (c) => {132149 const name = c.req.param("repo");src/server/app.ts+2 −0
@@ -7,6 +7,7 @@77 import { isSafePath } from "../bucket/repo.ts";88 import { registerArchivesPage } from "../pages/archives.ts";99 import { registerFileRoutes } from "../pages/files.ts";10+import { registerHousekeeping } from "../pages/housekeeping.ts";1011 import { registerReleasesPage } from "../pages/releases.ts";1112 import { registerRepoPage } from "../pages/repo.ts";1213 import { registerSavePage } from "../pages/save.ts";@@ -52,6 +53,7 @@5253 return c.body(await file.bytes(), 200, { "Content-Type": file.type });5354 });5455 const ctx = { domain: config.domain, kepr, mirrorDir: join(config.dataDir, "mirror") };56+ registerHousekeeping(app, ctx);5557 registerRepoPage(app, ctx);5658 registerFileRoutes(app, ctx);5759 registerSavePage(app, ctx);src/server/db.ts+9 −0
@@ -87,6 +87,15 @@8787 );8888 }8989 90+export function publicRepoNames(db: Database): string[] {91+ return db92+ .query<{ name: string }, []>(93+ "SELECT name FROM repos WHERE public = 1 ORDER BY sort_order, name",94+ )95+ .all()96+ .map((row) => row.name);97+}98+9099 /** The settings of a public repo, or null when it is private or unknown: both must look missing. */91100 export function publicRepo(db: Database, name: string): RepoSettings | null {92101 const row = dbtests/install.test.ts+122 −0
@@ -0,0 +1,122 @@1+import { afterAll, beforeAll, expect, test } from "bun:test";2+import { existsSync, readdirSync } from "node:fs";3+import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";4+import { tmpdir } from "node:os";5+import { join } from "node:path";6+import { openBucket } from "../src/bucket/reader.ts";7+import { syncAll } from "../src/bucket/sync.ts";8+import { createApp } from "../src/server/app.ts";9+import { openKeprDb } from "../src/server/db.ts";10+import { makeRepos, writeRelease } from "./fixture.ts";11+12+// install.sh runs for real: Kepr serves on localhost, and its downloads redirect to a stand-in S313+// server that serves the bucket folder (a folder source's file:// URLs are not followed by curl).14+// The script's https:// addresses are switched to http:// for the run; nothing else changes.15+16+const PLATFORM = `${process.platform === "darwin" ? "macos" : "linux"}-${process.arch === "arm64" ? "aarch64" : "x86_64"}`;17+const PLATFORMS = ["linux-x86_64", "linux-aarch64", "macos-aarch64", "macos-x86_64"];18+const binary = (version: string, platform: string) =>19+ new TextEncoder().encode(`#!/bin/sh\necho tool ${version} ${platform}\n`);20+21+let kepr: ReturnType<typeof Bun.serve>;22+let s3: ReturnType<typeof Bun.serve>;23+let bucketDir: string;24+let script: string;25+26+beforeAll(async () => {27+ const repos = await makeRepos(["tool"]);28+ bucketDir = repos.bucketDir;29+ for (const version of ["1.0.0", "1.1.0"]) {30+ const files = PLATFORMS.map((platform) => ({31+ platform,32+ name: "tool",33+ bytes: binary(version, platform),34+ }));35+ await writeRelease(bucketDir, "tool", { version, save: repos.saves.tool as string, files });36+ }37+ s3 = Bun.serve({38+ port: 0,39+ fetch: (req) =>40+ new Response(Bun.file(join(bucketDir, new URL(req.url).pathname.slice("/b/".length)))),41+ });42+ const dataDir = await mkdtemp(join(tmpdir(), "kepr-install-"));43+ const db = openKeprDb(dataDir);44+ await syncAll({45+ bucket: openBucket({ kind: "folder", path: bucketDir }),46+ kepr: db,47+ mirrorDir: join(dataDir, "mirror"),48+ tarballDir: join(dataDir, "tarballs"),49+ });50+ db.run("UPDATE repos SET public = 1");51+ kepr = Bun.serve({ port: 0, fetch: () => new Response("starting") });52+ const app = createApp(53+ {54+ domain: `127.0.0.1:${kepr.port}`,55+ dataDir,56+ adminPasswordHash: "$argon2id$x",57+ bucket: {58+ kind: "s3",59+ endpoint: `http://127.0.0.1:${s3.port}`,60+ name: "b",61+ region: "fr-par",62+ accessKeyId: "read",63+ secretAccessKey: "only",64+ },65+ },66+ db,67+ );68+ kepr.reload({ fetch: app.fetch });69+ const res = await fetch(`http://127.0.0.1:${kepr.port}/tool/install.sh`);70+ expect([res.status, res.headers.get("Content-Type")]).toEqual([200, "text/plain; charset=UTF-8"]);71+ script = await res.text();72+});73+74+afterAll(() => {75+ kepr.stop();76+ s3.stop();77+});78+79+async function install(): Promise<{ code: number; stderr: string; dir: string }> {80+ const home = await mkdtemp(join(tmpdir(), "kepr-install-home-"));81+ const path = join(home, "install.sh");82+ await writeFile(path, script.replaceAll("https://", "http://"));83+ const dir = join(home, "bin");84+ const run = Bun.spawn(["sh", path], {85+ cwd: home,86+ env: { PATH: process.env.PATH, HOME: home, INSTALL_DIR: dir },87+ stderr: "pipe",88+ stdout: "pipe",89+ });90+ return { code: await run.exited, stderr: await new Response(run.stderr).text(), dir };91+}92+93+test("install.sh is POSIX sh naming this repo and domain", async () => {94+ const dir = await mkdtemp(join(tmpdir(), "kepr-install-check-"));95+ await writeFile(join(dir, "install.sh"), script);96+ expect(Bun.spawnSync(["sh", "-n", join(dir, "install.sh")]).exitCode).toBe(0);97+ expect(script).toContain("repo=tool\n");98+ expect(script).toContain(`base=https://127.0.0.1:${kepr.port}/tool/releases\n`);99+});100+101+test("installs the newest build for this machine into INSTALL_DIR, executable", async () => {102+ const { code, stderr, dir } = await install();103+ expect([code, stderr]).toEqual([0, ""]);104+ const installed = join(dir, "tool");105+ expect(await readFile(installed)).toEqual(Buffer.from(binary("1.1.0", PLATFORM)));106+ expect((await stat(installed)).mode & 0o111).toBe(0o111);107+ expect(readdirSync(dir)).toEqual(["tool"]);108+});109+110+test("refuses a file that doesn't match checksums.txt, installing nothing", async () => {111+ await writeFile(join(bucketDir, "tool/releases/1.1.0", PLATFORM, "tool"), "tampered");112+ const { code, stderr, dir } = await install();113+ expect(code).not.toBe(0);114+ expect(stderr).toContain("checksum mismatch; nothing installed");115+ expect(existsSync(join(dir, "tool"))).toBe(false);116+ expect(readdirSync(dir)).toEqual([]);117+});118+119+test("a repo without releases has no installer", async () => {120+ const res = await fetch(`http://127.0.0.1:${kepr.port}/nowhere/install.sh`);121+ expect(res.status).toBe(404);122+});tests/pages.test.ts+25 −0
@@ -379,3 +379,28 @@379379 expect(csp).toContain("font-src https://fonts.bunny.net");380380 expect(csp).not.toContain("unsafe-inline");381381 });382+383+describe("housekeeping routes", () => {384+ test("robots.txt points at the sitemap, which lists public repos only", async () => {385+ expect(await body("/robots.txt")).toBe(386+ "User-agent: *\nAllow: /\n\nSitemap: https://kepr.example/sitemap.xml\n",387+ );388+ const sitemap = await get("/sitemap.xml");389+ expect(sitemap.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");390+ const xml = await sitemap.text();391+ expect(xml).toStartWith('<?xml version="1.0" encoding="UTF-8"?>\n<urlset');392+ for (const repo of ["demo", "large", "tail"]) {393+ expect(xml).toContain(`<loc>https://kepr.example/${repo}</loc>`);394+ }395+ expect(xml).not.toContain("hidden");396+ });397+398+ test("favicon.svg is Kepr 2's mark, and every page links it", async () => {399+ const res = await get("/favicon.svg");400+ expect(res.headers.get("Content-Type")).toBe("image/svg+xml");401+ expect(await res.text()).toContain('<polygon points="16,3 27,23 5,23"');402+ expect(await body("/demo")).toContain(403+ '<link rel="icon" href="/favicon.svg" type="image/svg+xml">',404+ );405+ });406+});