4ffd21d3611b

remove install lines and card order; repos sort by name and old kepr.db columns are dropped (v3.0.140)

time
2026-09-30 13:43 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
claude-opus-5-5
session
admin repo settings
parent
3c4ab82be15c
11 files package.json+1 −1src/admin/repos.ts+0 −14src/admin/store.ts+3 −19src/pages/repo.ts+1 −5src/server/db.ts+19 −20tests/backup.test.ts+1 −1tests/console.test.ts+3 −7tests/notifications.test.ts+1 −1tests/pages.test.ts+2 −4tests/repoworkspace.test.ts+17 −1tests/triage.test.ts+2 −3
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.139",3+	"version": "3.0.140",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/repos.ts+0 −14
@@ -12,7 +12,6 @@1212 1313 const MAX_FIELD = 500;1414 const HTTP_URL = /^https?:\/\/[^\s]+$/i;15-const SORT_ORDER = /^-?\d{1,6}$/;1615 const MAX_LANGUAGES = 5;1716 const MAX_LANGUAGE = 30;1817 @@ -43,10 +42,6 @@4342 			public: fields.public === "1" ? 1 : 0,4443 			category: text("category"),4544 			homepage: text("homepage"),46-			sort_order: Number(text("sort_order") || "0"),47-			install1: text("install1"),48-			install2: text("install2"),49-			install3: text("install3"),5045 			// Joined with the separator `publicRepo` splits on, so a name never contains it.5146 			languages: languages.join(", "),5247 			issues_paused: fields.issues_paused === "1" ? 1 : 0,@@ -58,10 +53,6 @@5853 		if (edit.homepage !== "" && !HTTP_URL.test(edit.homepage)) {5954 			return refuse("the homepage must be an http or https address.");6055 		}61-		if (!SORT_ORDER.test(text("sort_order") || "0")) return refuse("the order must be a number.");62-		if ([edit.install1, edit.install2, edit.install3].some((line) => /[\r\n]/.test(line))) {63-			return refuse("an install line is one line.");64-		}6556 		if (languages.length > MAX_LANGUAGES) return refuse("up to five languages.");6657 		if (languages.some((name) => name.length > MAX_LANGUAGE)) {6758 			return refuse(`a language name is at most ${MAX_LANGUAGE} characters.`);@@ -166,11 +157,6 @@166157 pause new issues</label>167158 ${input("category", "category")}168159 ${input("homepage", "homepage")}169-${input("sort_order", "card order")}170-<p class="issue-meta mono">Lower numbers appear first. Repositories with the same number sort by name.</p>171-${input("install1", "install line 1")}172-${input("install2", "install line 2")}173-${input("install3", "install line 3")}174160 ${input("languages", "shown languages")}175161 <p class="issue-meta mono">Up to five, separated by commas. Leave blank to show the detected one.</p>176162 <button class="issues-submit" type="submit">save ${repo.name}</button>

view file

src/admin/store.ts+3 −19
@@ -6,10 +6,6 @@66 	public: number;77 	category: string;88 	homepage: string;9-	sort_order: number;10-	install1: string;11-	install2: string;12-	install3: string;139 	/** Shown languages, joined by ", "; '' shows the detected one. */1410 	languages: string;1511 	issues_paused: number;@@ -20,7 +16,7 @@2016 export type RepoEdit = Omit<AdminRepo, "name" | "last_sync_at" | "last_sync_error">;2117 2218 export function allRepos(db: Database): AdminRepo[] {23-	return db.query<AdminRepo, []>("SELECT * FROM repos ORDER BY sort_order, name").all();19+	return db.query<AdminRepo, []>("SELECT * FROM repos ORDER BY name").all();2420 }2521 2622 export function findRepo(db: Database, name: string): AdminRepo | null {@@ -29,21 +25,9 @@2925 3026 export function updateRepo(db: Database, name: string, edit: RepoEdit): void {3127 	db.query(32-		`UPDATE repos SET public = ?, category = ?, homepage = ?, sort_order = ?, install1 = ?,33-		install2 = ?, install3 = ?, languages = ?, issues_paused = ?28+		`UPDATE repos SET public = ?, category = ?, homepage = ?, languages = ?, issues_paused = ?3429 		WHERE name = ?`,35-	).run(36-		edit.public,37-		edit.category,38-		edit.homepage,39-		edit.sort_order,40-		edit.install1,41-		edit.install2,42-		edit.install3,43-		edit.languages,44-		edit.issues_paused,45-		name,46-	);30+	).run(edit.public, edit.category, edit.homepage, edit.languages, edit.issues_paused, name);4731 }4832 4933 /** Writes site settings in one transaction; an empty value removes the key, so defaults apply. */

view file

src/pages/repo.ts+1 −5
@@ -93,7 +93,7 @@9393 }9494 9595 function header(ctx: PageContext, repo: PublicRepo, intro: Html | null): Html {96-	const { name, homepage, installLines, languages } = repo.settings;96+	const { name, homepage, languages } = repo.settings;9797 	const steal = `koh steal ${ctx.domain}/${name}`;9898 	const stats = repoStats(repo.mirror);9999 	const handle = setting(ctx.kepr, "handle");@@ -127,10 +127,6 @@127127 	)}</nav>128128 <div class="install-lines">129129 <button type="button" class="repo-steal-btn" data-copy="${steal}">${steal}</button>130-${installLines.map(131-	(line) =>132-		html`<div class="install-line"><code>${line}</code>${html`<button type="button" class="copy-btn" data-copy="${line}" hidden>copy</button>`}</div>`,133-)}134130 </div>135131 </div>136132 <div class="repo-panel" hidden></div>

view file

src/server/db.ts+19 −20
@@ -13,10 +13,6 @@1313 	public          INTEGER NOT NULL DEFAULT 0,1414 	category        TEXT NOT NULL DEFAULT '',1515 	homepage        TEXT NOT NULL DEFAULT '',16-	sort_order      INTEGER NOT NULL DEFAULT 0,17-	install1        TEXT NOT NULL DEFAULT '',18-	install2        TEXT NOT NULL DEFAULT '',19-	install3        TEXT NOT NULL DEFAULT '',2016 	languages       TEXT NOT NULL DEFAULT '',2117 	issues_paused   INTEGER NOT NULL DEFAULT 0,2218 	last_sync_at    INTEGER,@@ -131,13 +127,19 @@131127 	["repos", "issues_paused", "INTEGER NOT NULL DEFAULT 0"],132128 	["issue_entries", "hidden", "INTEGER NOT NULL DEFAULT 0"],133129 ];130+// Columns an older kepr.db still has; card order and install lines were removed (June, 2026-09-30).131+const DROPPED_COLUMNS: [table: string, column: string][] = [132+	["repos", "sort_order"],133+	["repos", "install1"],134+	["repos", "install2"],135+	["repos", "install3"],136+];134137 135138 /** `description` and `details` are the operator's Markdown, or '' for the README's. */136139 export type RepoSettings = {137140 	name: string;138141 	category: string;139142 	homepage: string;140-	installLines: string[];141143 	/** The operator's shown languages (§24), or none for the detected one. */142144 	languages: string[];143145 	/** Filing is paused (§23); the list, threads and follow-ups carry on. */@@ -152,9 +154,6 @@152154 	name: string;153155 	category: string;154156 	homepage: string;155-	install1: string;156-	install2: string;157-	install3: string;158157 	languages: string;159158 	issues_paused: number;160159 	description: string | null;@@ -167,13 +166,18 @@167166 	db.run("PRAGMA journal_mode = WAL");168167 	db.run("PRAGMA busy_timeout = 5000");169168 	db.run(SCHEMA);169+	// Every name below comes from the two lists above, never from input.170+	const has = (table: string, column: string) =>171+		db172+			.query<{ name: string }, []>(`PRAGMA table_info(${table})`)173+			.all()174+			.some((existing) => existing.name === column);170175 	for (const [table, column, definition] of ADDED_COLUMNS) {171-		// Both names come from the list above, never from input.172-		const columns = db.query<{ name: string }, []>(`PRAGMA table_info(${table})`).all();173-		if (!columns.some((existing) => existing.name === column)) {174-			db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);175-		}176+		if (!has(table, column)) db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);176177 	}178+	for (const [table, column] of DROPPED_COLUMNS) {179+		if (has(table, column)) db.run(`ALTER TABLE ${table} DROP COLUMN ${column}`);180+	}177181 	return db;178182 }179183 @@ -238,9 +242,7 @@238242 239243 export function publicRepoNames(db: Database): string[] {240244 	return db241-		.query<{ name: string }, []>(242-			"SELECT name FROM repos WHERE public = 1 ORDER BY sort_order, name",243-		)245+		.query<{ name: string }, []>("SELECT name FROM repos WHERE public = 1 ORDER BY name")244246 		.all()245247 		.map((row) => row.name);246248 }@@ -249,19 +251,16 @@249251 export function publicRepo(db: Database, name: string): RepoSettings | null {250252 	const row = db251253 		.query<RepoRow, [string]>(252-			`SELECT name, category, homepage, install1, install2, install3, languages, issues_paused,253-			description, details254+			`SELECT name, category, homepage, languages, issues_paused, description, details254255 			FROM repos LEFT JOIN repo_text ON repo_text.repo = repos.name255256 			WHERE name = ? AND public = 1`,256257 		)257258 		.get(name);258259 	if (!row) return null;259-	const installLines = [row.install1, row.install2, row.install3].filter((line) => line !== "");260260 	return {261261 		name: row.name,262262 		category: row.category,263263 		homepage: row.homepage,264-		installLines,265264 		languages: row.languages === "" ? [] : row.languages.split(", "),266265 		issuesPaused: row.issues_paused === 1,267266 		description: row.description ?? "",

view file

tests/backup.test.ts+1 −1
@@ -56,7 +56,7 @@5656 		old = await instance(bucketDir);5757 		operator = new Browser(old.app);5858 		await operator.signIn();59-		await operator.post("/admin/repos/app", { public: "1", category: "tools", sort_order: "3" });59+		await operator.post("/admin/repos/app", { public: "1", category: "tools" });6060 		const site = new FormData();6161 		for (const [key, value] of Object.entries({6262 			site_name: "restored",

view file

tests/console.test.ts+3 −7
@@ -52,7 +52,7 @@5252 	};5353 	const text = async (path: string) => (await inst.app.request(path)).text();5454 	const publish = (repo: string, fields: Record<string, string>) =>55-		browser.post(`/admin/repos/${repo}`, { sort_order: "0", ...fields });55+		browser.post(`/admin/repos/${repo}`, fields);5656 5757 	beforeAll(async () => {5858 		setSystemTime(new Date(clock));@@ -96,8 +96,6 @@9696 	test("bad settings are refused and change nothing", async () => {9797 		for (const fields of <Record<string, string>[]>[9898 			{ homepage: "javascript:alert(1)" },99-			{ sort_order: "first" },100-			{ install1: "one\ntwo" },10199 			{ category: "x".repeat(501) },102100 		]) {103101 			expect((await publish("app", { public: "1", ...fields })).status).toBe(400);@@ -112,13 +110,11 @@112110 			public: "1",113111 			category: "tools",114112 			homepage: "https://app.example",115-			install1: "nix profile add app",116113 		});117114 		expect(response.status).toBe(303);118115 		const lines = await statuses();119116 		expect(lines.filter((line) => line.endsWith(" 404"))).toEqual([]);120117 		expect(await text("/app")).toContain("https://app.example");121-		expect(await text("/app")).toContain("nix profile add app");122118 		expect(await text("/")).toContain('href="/app"');123119 		expect(await text("/browse")).toContain('href="/app"');124120 		expect(await text("/@june")).toContain('href="/app"');@@ -171,7 +167,7 @@171167 		inst = await instance(repos.bucketDir);172168 		operator = new Browser(inst.app);173169 		await operator.signIn();174-		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });170+		await operator.post("/admin/repos/app", { public: "1" });175171 		// A reporter files through the public form, as a browser would.176172 		const reporter = new Browser(inst.app, "198.51.100.40");177173 		const form = await (await reporter.request("/app/issues/new")).text();@@ -281,7 +277,7 @@281277 		inst = await instance(repos.bucketDir);282278 		operator = new Browser(inst.app);283279 		await operator.signIn();284-		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });280+		await operator.post("/admin/repos/app", { public: "1" });285281 		await inst.app.request("/app/releases/1.1.0/linux-x86_64");286282 	});287283 

view file

tests/notifications.test.ts+1 −1
@@ -31,7 +31,7 @@3131 		inst = await instance(repos.bucketDir);3232 		operator = new Browser(inst.app);3333 		await operator.signIn();34-		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });34+		await operator.post("/admin/repos/app", { public: "1" });3535 		clear();3636 		const reporter = new Browser(inst.app, "198.51.100.41");3737 		const form = await (await reporter.request("/app/issues/new")).text();

view file

tests/pages.test.ts+2 −4
@@ -64,9 +64,7 @@6464 		const bucket = openBucket({ kind: "folder", path: bucketDir });6565 		await syncAll({ bucket, kepr, mirrorDir, tarballDir: join(dataDir, "tarballs") });6666 	}67-	kepr.run(68-		"UPDATE repos SET public = 1, install1 = 'nix profile add x', homepage = 'https://demo.example' WHERE name = 'demo'",69-	);67+	kepr.run("UPDATE repos SET public = 1, homepage = 'https://demo.example' WHERE name = 'demo'");7068 	kepr.run("UPDATE repos SET public = 1 WHERE name IN ('large', 'tail')");7169 	app = createApp(7270 		{@@ -110,7 +108,7 @@110108 		expect(page).toContain('href="https://demo.example"');111109 		expect(page).toContain('<span class="lang-badge">TypeScript</span>');112110 		expect(page).toContain("3 saves");113-		expect(page).toContain('data-copy="nix profile add x"');111+		expect(page).not.toContain('class="install-line"');114112 		expect(page.match(/class="dot d\d"/g)?.length).toBe(3);115113 		expect(page).not.toContain("density = activity");116114 		expect(historyIds(page)).toEqual([head(), second(), first()]);

view file

tests/repoworkspace.test.ts+17 −1
@@ -15,7 +15,7 @@1515 	let browser: Browser;1616 	const workspace = async () => (await browser.get("/admin/repos?repo=app")).text();1717 	const save = (fields: Record<string, string>) =>18-		browser.post("/admin/repos/app", { public: "1", sort_order: "0", ...fields });18+		browser.post("/admin/repos/app", { public: "1", ...fields });1919 2020 	beforeAll(async () => {2121 		const repos = await makeRepos(["app"]);@@ -119,4 +119,20 @@119119 	expect(db.query<{ languages: string }, []>("SELECT languages FROM repos").get()?.languages).toBe(120120 		"",121121 	);122+});123+124+test("an older kepr.db loses card order and install lines, keeping each repo", async () => {125+	const dir = await mkdtemp(join(tmpdir(), "kepr-old-db-"));126+	const old = new Database(join(dir, "kepr.db"));127+	old.run(`CREATE TABLE repos (128+		name TEXT PRIMARY KEY, public INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0,129+		install1 TEXT NOT NULL DEFAULT '', install2 TEXT NOT NULL DEFAULT '',130+		install3 TEXT NOT NULL DEFAULT '')`);131+	old.run("INSERT INTO repos (name, public, install1) VALUES ('app', 1, 'nix profile add app')");132+	old.close();133+	const db = openKeprDb(dir);134+	const columns = db.query<{ name: string }, []>("PRAGMA table_info(repos)").all();135+	expect(columns.map((column) => column.name)).not.toContain("install1");136+	expect(columns.map((column) => column.name)).not.toContain("sort_order");137+	expect(db.query("SELECT name, public FROM repos").all()).toEqual([{ name: "app", public: 1 }]);122138 });

view file

tests/triage.test.ts+2 −3
@@ -40,7 +40,7 @@4040 		operator = new Browser(inst.app);4141 		await operator.signIn();4242 		for (const repo of ["app", "tools"]) {43-			await operator.post(`/admin/repos/${repo}`, { public: "1", sort_order: "0" });43+			await operator.post(`/admin/repos/${repo}`, { public: "1" });4444 		}4545 		file("app", "Crash on start", "A segfault in main", "bug");4646 		file("app", "Dark mode", "Please", "idea");@@ -111,7 +111,6 @@111111 	test("pausing new issues stops filing but keeps the list and follow-ups", async () => {112112 		const pause = await operator.post("/admin/repos/app", {113113 			public: "1",114-			sort_order: "0",115114 			issues_paused: "1",116115 		});117116 		expect(pause.status).toBe(303);@@ -131,7 +130,7 @@131130 		expect(await text("/app/issues")).not.toContain("report an issue →");132131 		expect(await text("/app/issues")).toContain('href="/app/issues/1"');133132 		expect(await text(`/app/issues/3?token=${token}`)).toContain('name="body"');134-		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });133+		await operator.post("/admin/repos/app", { public: "1" });135134 		expect(await text("/app/issues/new")).toContain('name="title"');136135 	});137136 });

view file