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
↓ kepr-a7772f7d.tar.gzatlas at this save
files changed6 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 −0package.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": {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>`;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();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 dbsrc/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. */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+});