3be930495fbc

switch the bucket source between S3 and a folder remote either way from the console (v3.0.157)

time
2026-09-30 16:13 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
operator console
parent
a602ee4ac891
3 files package.json+1 −1src/admin/bucket.ts+40 −9tests/bucketadmin.test.ts+18 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.156",3+	"version": "3.0.157",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/bucket.ts+40 −9
@@ -1,5 +1,8 @@1+import { stat } from "node:fs/promises";2+import { isAbsolute } from "node:path";13 import type { Context, Hono } from "hono";24 import { checkS3Access, openBucket } from "../bucket/reader.ts";5+import { listRepos } from "../bucket/repo.ts";36 import { html } from "../pages/html.ts";47 import { type BucketSource, saveConfig } from "../server/config.ts";58 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";@@ -68,9 +71,22 @@6871 	};6972 }7073 74+/** A folder remote a form names, which Kepr must be able to list; or what is wrong. */75+async function folderCandidate(folder: string): Promise<BucketSource | string> {76+	const path = folder.trim();77+	if (!isAbsolute(path)) return "The folder is a full path, starting with /.";78+	try {79+		if (!(await stat(path)).isDirectory()) throw new Error("not a directory");80+		await listRepos(openBucket({ kind: "folder", path }));81+	} catch {82+		return `${path} is not a folder Kepr can read.`;83+	}84+	return { kind: "folder", path };85+}86+7187 /**72- * §24's Bucket section: edit and test the S3 settings, then write them to kepr.toml (§19). A folder73- * instance can switch to S3 here (June, 2026-09-30); only the file switches it back.88+ * §24's Bucket section: edit and test the S3 settings, or name a folder remote, then write them to89+ * kepr.toml (§19). Either kind switches to the other here (June, 2026-09-30).7490  */7591 export function registerBucket(app: Hono, ctx: AdminContext): void {7692 	app.get("/admin/bucket", (c) => c.html(bucketPage(c, ctx)));@@ -78,11 +94,14 @@7894 	app.post("/admin/bucket", async (c) => {7995 		const current = ctx.config.bucket;8096 		const fields = await textFields(c);81-		const next = candidate(fields, current.kind === "s3" ? current : null);97+		const next =98+			fields.action === "folder"99+				? await folderCandidate(fields.folder ?? "")100+				: candidate(fields, current.kind === "s3" ? current : null);82101 		if (typeof next === "string") return c.html(bucketPage(c, ctx, { fields, error: next }), 400);83-		const failure = await checkS3Access(next);102+		const failure = next.kind === "s3" ? await checkS3Access(next) : null;84103 		if (failure) return c.html(bucketPage(c, ctx, { fields, error: failure }), 400);85-		if (fields.action !== "save") {104+		if (fields.action === "test") {86105 			return c.html(bucketPage(c, ctx, { fields, status: "Connection works. Nothing was saved." }));87106 		}88107 		try {@@ -127,7 +146,7 @@127146 ${128147 	source.kind === "folder"129148 		? html`<p class="admin-intro">This instance reads a Koh folder remote at130-<span class="mono">${source.path}</span>. Saving S3 settings below switches it to that bucket.</p>`149+<span class="mono">${source.path}</span>. Saving S3 settings switches it to that bucket.</p>`131150 		: html`<p class="admin-intro">The S3 bucket Kepr reads with its read-only key. Secrets are never shown.</p>`132151 }133152 <form class="issues-form" method="post" action="/admin/bucket">@@ -136,12 +155,24 @@136155 ${secret("secret_access_key", "read secret key", s3 ? "unchanged" : "")}137156 ${input(SHOWN[4] as (typeof SHOWN)[number])}138157 ${secret("write_secret_access_key", "write secret key", s3?.write ? "unchanged" : "")}139-<p class="muted">The write key is optional: it signs machines in (/connect). Clear its ID to turn that140-off. Saving tests the settings first and rewrites kepr.toml without its comments.</p>158+<p class="muted">The read key is all Kepr needs to show your repos. The write key is optional: with159+it Kepr keeps tarballs and uploads under .kepr/, deletes builds you delete, and signs machines in160+(/connect). Clear its ID to go without. Saving tests the settings first; secrets are never shown.</p>141161 <div class="issues-actions">142162 <button class="issues-submit" type="submit" name="action" value="test">test connection</button>143163 <button class="issues-submit" type="submit" name="action" value="save">save</button>144164 </div>145-</form>`;165+</form>166+<section class="repo-section"><h2 class="section-label mono">folder remote</h2>167+<form class="issues-form" method="post" action="/admin/bucket">168+${formField(c)}169+<label class="issues-label" for="folder">folder</label>170+<input class="issues-input mono" id="folder" name="folder"171+value="${shown.fields?.folder ?? (source.kind === "folder" ? source.path : "")}"172+autocomplete="off" spellcheck="false" placeholder="/path/to/koh/folder/remote">173+<p class="muted">For running Kepr on your own machine: a Koh folder remote on this server instead174+of S3.</p>175+<button class="issues-submit" type="submit" name="action" value="folder">use this folder</button>176+</form></section>`;146177 	return adminPage(c, ctx, "bucket", body);147178 }

view file

tests/bucketadmin.test.ts+18 −0
@@ -204,4 +204,22 @@204204 			`http://127.0.0.1:${first.port}/koh/app/x`,205205 		);206206 	});207+208+	test("an S3 instance can switch to a folder remote, which must exist", async () => {209+		const { browser, configPath, shared, original } = await build();210+		const missing = await browser.post("/admin/bucket", {211+			action: "folder",212+			folder: "/no/such/dir",213+		});214+		expect(missing.status).toBe(400);215+		expect(await missing.text()).toContain("is not a folder Kepr can read");216+		const relative = await browser.post("/admin/bucket", { action: "folder", folder: "bucket" });217+		expect(relative.status).toBe(400);218+		expect(await Bun.file(configPath).text()).toBe(original);219+		const folder = await mkdtemp(join(tmpdir(), "kepr-folder-remote-"));220+		const saved = await browser.post("/admin/bucket", { action: "folder", folder });221+		expect(saved.status).toBe(303);222+		expect((await loadConfig(configPath)).bucket).toEqual({ kind: "folder", path: folder });223+		expect(shared.downloadUrl("app/x", "x", "t/x")).toStartWith("file://");224+	});207225 });

view file