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

view file

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 file

view file

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

view file

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">

view file

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"

view file

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");

view file

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

view file

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 = db

view file

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

view file

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

view file