a7772f7d8854

hide names ignored in every repo, set in the site section, at any depth from pages, stats and tarballs (v3.0.178)

time
2026-10-01 18:28 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
tylee kepr 3 cutover
parent
efd0734feb70
6 files package.json+1 −1src/admin/site.ts+34 −2src/bucket/sync.ts+28 −0src/mirror/store.ts+17 −1src/server/db.ts+12 −2tests/ignored.test.ts+94 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.177",3+	"version": "3.0.178",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/site.ts+34 −2
@@ -2,9 +2,11 @@22 import { mkdirSync, rmSync, writeFileSync } from "node:fs";33 import { join } from "node:path";44 import type { Context, Hono } from "hono";5+import { isSafePath } from "../bucket/repo.ts";6+import { reapplyHidden } from "../bucket/sync.ts";57 import { supportText } from "../pages/docs.ts";68 import { html } from "../pages/html.ts";7-import { setting } from "../server/db.ts";9+import { ignoredNames, setting } from "../server/db.ts";810 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";911 import { saveSettings } from "./store.ts";1012 @@ -27,6 +29,8 @@2729 // Under the 64 KiB body cap with room for the form's other fields; the docs promise 60 KB.2830 const MAX_SIGNATURE = 60 * 1024;2931 const OWN_SIGNATURE = /^signature-[0-9a-f]{16}\.(png|jpg|webp)$/;32+const MAX_IGNORED = 50;33+const MAX_IGNORED_NAME = 255;3034 3135 /**3236  * The signature's type, from its first bytes, never its name or declared type. PNG, JPEG and WebP@@ -62,6 +66,21 @@6266 		}6367 		// Shown by default: the setting records only that it was switched off.6468 		if (section !== "profile") values.support_off = fields.support === "1" ? "" : "1";69+		const ignored = [70+			...new Set(71+				(fields.ignored ?? "")72+					.split(/\r?\n/)73+					.map((name) => name.trim())74+					.filter(Boolean),75+			),76+		];77+		if (ignored.length > MAX_IGNORED) return refuse(`Up to ${MAX_IGNORED} ignored names.`);78+		const bad = ignored.find(79+			(name) => name.length > MAX_IGNORED_NAME || name.includes("/") || !isSafePath(name),80+		);81+		if (bad !== undefined) return refuse(`"${bad}" is not a file or folder name.`);82+		const previousIgnored = ignoredNames(ctx.kepr);83+		if (section !== "profile") values.ignored = ignored.join("\n");6584 		if (values.link && !HTTP_URL.test(values.link)) {6685 			return refuse("The link must be an http or https address.");6786 		}@@ -97,6 +116,13 @@97116 		) {98117 			rmSync(join(assets, previous), { force: true });99118 		}119+		if (values.ignored !== undefined && values.ignored !== previousIgnored.join("\n")) {120+			const rebuild = previousIgnored.some((name) => !ignored.includes(name));121+			// Not awaited: it waits for any sync under way, which can take minutes on a big repo.122+			reapplyHidden(ctx.sync, rebuild).catch((error) =>123+				console.error(`kepr: applying ignored names failed: ${error}`),124+			);125+		}100126 		return c.redirect(`/admin/site?${section === "profile" ? "section=profile&" : ""}saved=1`, 303);101127 	});102128 }@@ -137,7 +163,13 @@137163 <label class="issues-label"><input type="checkbox" name="support" value="1"${138164 		(fields ? fields.support === "1" : !setting(ctx.kepr, "support_off")) && " checked"139165 	}>140-show the support page</label>`166+show the support page</label>167+<label class="issues-label" for="ignored">ignored in every repo</label>168+<p class="admin-intro">One file or folder name per line, such as .agent or AGENTS.md: hidden at169+any depth in every repo, from pages and tarballs. Applied after any sync under way.</p>170+<textarea class="issues-textarea mono" id="ignored" name="ignored" rows="4">${171+		fields ? (fields.ignored ?? "") : ignoredNames(ctx.kepr).join("\n")172+	}</textarea>`141173 }142174 <button class="issues-submit" type="submit">save ${profile ? "profile" : "site"}</button>143175 </form>`;

view file

src/bucket/sync.ts+28 −0
@@ -62,6 +62,34 @@6262 	await syncAll(target);6363 }6464 65+/**66+ * Applies a changed list of names ignored in every repo. Once any sync under way has finished (its67+ * later batches would land with the old list), a longer list is applied to each mirror in place; a68+ * name taken off needs its files back, so every mirror is rebuilt.69+ */70+export async function reapplyHidden(target: SyncTarget, rebuild: boolean): Promise<void> {71+	const repos = await listRepos(target.bucket);72+	await syncAll(target);73+	for (const repo of repos) {74+		const mirror = openMirror(target.mirrorDir, repo);75+		const head = mirrorState(mirror, "head");76+		if (rebuild) clearMirror(mirror);77+		else if (head) {78+			land(mirror, {79+				head,80+				releasesLatest: mirrorState(mirror, "releases_latest") ?? "",81+				saves: [],82+				objects: new Map(),83+				events: [],84+				releases: [],85+				quarantined: [],86+				hidden: hiddenPaths(target.kepr, repo),87+			});88+		}89+	}90+	if (rebuild) await syncAll(target);91+}92+6593 export function startSyncLoop(target: SyncTarget): void {6694 	const tick = () => syncAll(target).catch((error) => console.error(`kepr: sync failed: ${error}`));6795 	tick();

view file

src/mirror/store.ts+17 −1
@@ -69,10 +69,16 @@6969 	releases: ReleaseRecord[];7070 	/** Escaped names of the paths these saves arrived without (§5.5, A3). */7171 	quarantined: string[];72-	/** The operator's hidden paths (June, 2026-09-30), removed from every save like a strip. */72+	/**73+	 * The operator's hidden paths (June, 2026-09-30), removed from every save like a strip, and the74+	 * names ignored in every repo (June, 2026-10-01), each as `ANY_DEPTH` + name.75+	 */7376 	hidden: string[];7477 };7578 79+/** Marks a hidden entry as a name matched at any depth rather than a path from the root. */80+export const ANY_DEPTH = "**/";81+7682 // A recount inflates every file at the newest save; a file this large is data, not lines of code.7783 const MAX_COUNTED = 8 * 1024 * 1024;7884 @@ -256,6 +262,16 @@256262 257263 /** Removes a path, file or folder, from one save or ('') every save; returns the rows removed. */258264 function removePath(db: Database, path: string, save: string): number {265+	if (path.startsWith(ANY_DEPTH)) {266+		// A name ignored in every repo: any path with a segment of exactly that name.267+		return db268+			.query(269+				`DELETE FROM save_files270+				WHERE instr('/' || path || '/', $segment) > 0271+				AND ($save = '' OR save_id = $save)`,272+			)273+			.run({ segment: `/${path.slice(ANY_DEPTH.length)}/`, save }).changes;274+	}259275 	// Koh compares the prefix exactly, never with LIKE, so `_` and `%` in a path match only themselves.260276 	const dir = `${path.replace(/\/+$/, "")}/`;261277 	return db

view file

src/server/db.ts+12 −2
@@ -1,6 +1,7 @@11 import { Database } from "bun:sqlite";22 import { mkdirSync } from "node:fs";33 import { join } from "node:path";4+import { ANY_DEPTH } from "../mirror/store.ts";45 56 // §19: only what the pages, the sync loop and issues use so far; later phases add their tables.67 const SCHEMA = `@@ -286,12 +287,21 @@286287 	};287288 }288289 289-/** The paths the operator hid from a repo (June, 2026-09-30), one per line in kepr.db. */290+/**291+ * What a sync removes from a repo: the paths the operator hid from it (June, 2026-09-30) and the292+ * names ignored in every repo (June, 2026-10-01), both one per line in kepr.db.293+ */290294 export function hiddenPaths(db: Database, repo: string): string[] {291295 	const row = db292296 		.query<{ hidden_paths: string }, [string]>("SELECT hidden_paths FROM repos WHERE name = ?")293297 		.get(repo);294-	return row?.hidden_paths ? row.hidden_paths.split("\n") : [];298+	const paths = row?.hidden_paths ? row.hidden_paths.split("\n") : [];299+	return [...paths, ...ignoredNames(db).map((name) => `${ANY_DEPTH}${name}`)];300+}301+302+/** Names of files and folders hidden at any depth in every repo. */303+export function ignoredNames(db: Database): string[] {304+	return setting(db, "ignored")?.split("\n").filter(Boolean) ?? [];295305 }296306 297307 /** A repo's own texts, public or not; '' where the README speaks. */

view file

tests/ignored.test.ts+94 −0
@@ -0,0 +1,94 @@1+// Covers names ignored in every repo (June, 2026-10-01): a file or folder with that exact name, at2+// any depth, is gone from every repo at once, stays gone in later saves, and comes back when the3+// name is taken off the list.4+5+import { beforeAll, describe, expect, test } from "bun:test";6+import { mkdir, writeFile } from "node:fs/promises";7+import { join } from "node:path";8+import { openBucket } from "../src/bucket/reader.ts";9+import { syncAll } from "../src/bucket/sync.ts";10+import { Browser, type Instance, instance } from "./browser.ts";11+import { makeRepos } from "./fixture.ts";12+13+describe("names ignored in every repo", () => {14+	let inst: Instance;15+	let browser: Browser;16+	let repos: Awaited<ReturnType<typeof makeRepos>>;17+	const status = async (path: string) => `${path} ${(await inst.app.request(path)).status}`;18+	const ignore = (ignored: string) => browser.post("/admin/site", { section: "site", ignored });19+	const sync = () =>20+		syncAll({21+			bucket: openBucket({ kind: "folder", path: repos.bucketDir }),22+			kepr: inst.kepr,23+			mirrorDir: join(inst.dataDir, "mirror"),24+		});25+	// The console applies a change after its response, once any sync under way has finished.26+	const settled = async (path: string, want: number) => {27+		for (let i = 0; i < 100 && (await inst.app.request(path)).status !== want; i++) {28+			await Bun.sleep(50);29+		}30+	};31+	const gone = ["/app/raw/AGENTS.md", "/app/raw/.agent/notes.md", "/lib/raw/src/AGENTS.md"];32+	const kept = ["/app/raw/AGENTS.md.bak", "/app/raw/.agents/x.md", "/lib/raw/src/main.ts"];33+34+	beforeAll(async () => {35+		repos = await makeRepos(["app", "lib"]);36+		const files: [string, string][] = [37+			["app/AGENTS.md", "rules\n"],38+			["app/AGENTS.md.bak", "old rules\n"],39+			["app/.agent/notes.md", "notes\n"],40+			["app/.agents/x.md", "not the same name\n"],41+			["lib/src/AGENTS.md", "nested rules\n"],42+			["lib/src/main.ts", "export const a = 1;\n"],43+		];44+		for (const [path, text] of files) {45+			const full = join(repos.bucketDir, "..", "work", path);46+			await mkdir(join(full, ".."), { recursive: true });47+			await writeFile(full, text);48+		}49+		for (const repo of ["app", "lib"]) {50+			repos.koh(repo, "save", "add agent files");51+			repos.koh(repo, "offer");52+		}53+		inst = await instance(repos.bucketDir);54+		browser = new Browser(inst.app);55+		await browser.signIn();56+		for (const repo of ["app", "lib"]) {57+			await browser.post(`/admin/repos/${repo}`, { public: "1", hidden_paths: "" });58+		}59+	});60+61+	test("refuses anything but a plain name", async () => {62+		for (const name of ["src/AGENTS.md", "..", "a\u0000b", "x".repeat(256)]) {63+			expect((await ignore(name)).status).toBe(400);64+		}65+		expect(await status(gone[0] as string)).toBe(`${gone[0]} 200`);66+	});67+68+	test("an ignored name is gone from every repo at any depth, and only that name", async () => {69+		expect((await ignore(".agent\r\nAGENTS.md\n")).status).toBe(303);70+		await settled(gone[2] as string, 404);71+		for (const path of gone) expect(await status(path)).toBe(`${path} 404`);72+		for (const path of kept) expect(await status(path)).toBe(`${path} 200`);73+		expect(await (await browser.get("/admin/site")).text()).toContain(74+			">.agent\nAGENTS.md</textarea>",75+		);76+	});77+78+	test("stays ignored in saves that arrive later", async () => {79+		await writeFile(join(repos.bucketDir, "..", "work", "app", "AGENTS.md"), "new rules\n");80+		repos.koh("app", "save", "change the rules");81+		repos.koh("app", "offer");82+		await sync();83+		expect(await status("/app/raw/AGENTS.md")).toBe("/app/raw/AGENTS.md 404");84+		expect(await (await inst.app.request("/app")).text()).toContain("change the rules");85+	});86+87+	test("taking a name off the list brings its files back", async () => {88+		expect((await ignore(".agent")).status).toBe(303);89+		await settled("/lib/raw/src/AGENTS.md", 200);90+		expect(await status("/lib/raw/src/AGENTS.md")).toBe("/lib/raw/src/AGENTS.md 200");91+		expect(await (await inst.app.request("/app/raw/AGENTS.md")).text()).toBe("new rules\n");92+		expect(await status("/app/raw/.agent/notes.md")).toBe("/app/raw/.agent/notes.md 404");93+	});94+});

view file