Public: anyone can read this; the crew can change it.
An admin deletes a repository from the terminal's `:` palette after typing its full name, as Settings' Delete this repository does #8147
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8031-an-admin-deletes-a-repository-from-the-t → main 6 commits fork 2 threads · 2 unresolved
5 files
changed +263
−0
README.md +2
| @@ -75,6 +75,8 @@ In the terminal, `T` on a shipped pull's screen takes it back, as the pull page' | ||
| 75 | 75 | |
| 76 | 76 | In the terminal, `m` on a local repository's issue list opens the plan screen (`src/tui/screens/plan.ts`): Working, Ready now, Waiting and Parked twice, each headed with its count. It reads what MCP `map` reads (`repoGraph` under the reader's sealed scope, `pillarView`, `mapHeldFor`, the runs in flight), so a sealed issue the reader may not see is left out. Enter opens the issue, `r` reads again, and the headings are skipped over. It is not offered on the pulls list or on a live browse of another forge. |
| 77 | 77 | |
| 78 | In the terminal, a repository screen's `:` palette offers **Delete this repository** (`<owner>/<name> — Delete this repository`, "cannot be undone") to a person who administers it, never to an agent, a writer who is not an admin or a reader. It asks for the full `<owner>/<name>`; anything else is refused with "Type <owner>/<name> to confirm." and nothing changes. The exact name calls `deleteRepo` (`src/actions/repos.ts`, as Settings' danger zone does), toasts the result and unwinds the stack to the screen beneath the repository's (`leaveRepo`), so no screen reads the deleted row. | |
| 79 | ||
| 78 | 80 | In the terminal, the palette's **Organization teams** (offered to anyone on a team of an organization) lists each organization's teams you may see, as `/<org>/-/teams` does (an org admin sees every team, a member only their own), each with its member count and permission; Enter lists the team's members. On that list an admin of the organization adds a person with `a` (type a username) and removes the member under the cursor with `d` after a y/n, in the Teams page's words; a member who is not an org admin is offered neither key. |
| 79 | 81 | |
| 80 | 82 | In the terminal, the palette's **Connected origins** lists the hosts your mirrors come from (a signed-in person's, not an agent's): `s` syncs the selected one now, `d` disconnects it after a confirm; see `docs/ORIGINS.md`. |
src/tui/app/app.ts +15
| @@ -88,6 +88,8 @@ export interface Screen { | ||
| 88 | 88 | load?(signal: AbortSignal): Promise<void>; |
| 89 | 89 | /** Palette rows only this screen offers, listed after the app's own. */ |
| 90 | 90 | commands?(): Command[]; |
| 91 | /** The local repository this screen shows, so `leaveRepo` can find it when it is deleted. */ | |
| 92 | readonly repoId?: string; | |
| 91 | 93 | } |
| 92 | 94 | |
| 93 | 95 | /** |
| @@ -139,6 +141,11 @@ export interface Ctx { | ||
| 139 | 141 | push(screen: Screen): void; |
| 140 | 142 | pop(): void; |
| 141 | 143 | replace(screen: Screen): void; |
| 144 | /** | |
| 145 | * A repository is gone: drop its lowest screen and everything above it, down | |
| 146 | * to the one beneath (never the root), so nothing reads the deleted row. | |
| 147 | */ | |
| 148 | leaveRepo(repoId: string): void; | |
| 142 | 149 | invalidate(): void; |
| 143 | 150 | toast(text: string, tone?: Tone): void; |
| 144 | 151 | /** Where the terminal caret belongs this frame, if anywhere. */ |
| @@ -395,6 +402,7 @@ export class App { | ||
| 395 | 402 | push: (s) => app.push(s), |
| 396 | 403 | pop: () => app.pop(), |
| 397 | 404 | replace: (s) => app.replace(s), |
| 405 | leaveRepo: (id) => app.leaveRepo(id), | |
| 398 | 406 | invalidate: () => app.invalidate(), |
| 399 | 407 | toast: (text, tone) => app.toast(text, tone), |
| 400 | 408 | track: (work) => app.track(work), |
| @@ -468,6 +476,13 @@ export class App { | ||
| 468 | 476 | this.invalidate(); |
| 469 | 477 | } |
| 470 | 478 | |
| 479 | leaveRepo(repoId: string): void { | |
| 480 | const at = this.stack.findIndex((e) => e.screen.repoId === repoId); | |
| 481 | if (at < 0) return; | |
| 482 | for (const e of this.stack.splice(Math.max(at, 1))) e.abort.abort(); | |
| 483 | this.invalidate(); | |
| 484 | } | |
| 485 | ||
| 471 | 486 | replace(screen: Screen): void { |
| 472 | 487 | this.stack.pop()?.abort.abort(); |
| 473 | 488 | this.push(screen); |
src/tui/screens/deleterepo.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** | |
| 2 | * Delete this repository, from a repository screen's `:` palette (#8031): the | |
| 3 | * terminal's door to Settings' danger zone. The full name is typed before | |
| 4 | * anything goes, and only a person who administers the repository is offered it. | |
| 5 | */ | |
| 6 | ||
| 7 | import { deleteRepo } from "../../actions/repos.ts"; | |
| 8 | import { canAdmin, type Repo } from "../../core/repos.ts"; | |
| 9 | import { DELETE_REPO } from "../../web/danger_words.ts"; | |
| 10 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 11 | import type { Command } from "../app/palette.ts"; | |
| 12 | import type { Session } from "../session.ts"; | |
| 13 | import { promptScreen } from "./common.ts"; | |
| 14 | ||
| 15 | const GOES = | |
| 16 | "every issue, pull request, comment and webhook, and the git objects; forks are detached first and keep working"; | |
| 17 | ||
| 18 | /** The prompt floats over whatever is already drawn; this is the empty backdrop it asks for. */ | |
| 19 | const backdrop: Screen = { | |
| 20 | title: DELETE_REPO, | |
| 21 | keys: [], | |
| 22 | render() {}, | |
| 23 | key: () => false, | |
| 24 | }; | |
| 25 | ||
| 26 | export function deleteRepoCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 27 | if (repo === undefined || f.user === undefined || f.user.kind !== "user") return []; | |
| 28 | if (!canAdmin(f.db, repo, f.user)) return []; | |
| 29 | const full = `${repo.ownerName}/${repo.name}`; | |
| 30 | return [ | |
| 31 | { | |
| 32 | id: "delete-repository", | |
| 33 | title: `${full} — ${DELETE_REPO}`, | |
| 34 | hint: "cannot be undone", | |
| 35 | run: () => | |
| 36 | ctx.push( | |
| 37 | promptScreen(ctx, backdrop, { | |
| 38 | title: `${DELETE_REPO} (cannot be undone)`, | |
| 39 | label: `Removes ${GOES}. Type ${full}:`, | |
| 40 | placeholder: full, | |
| 41 | onSubmit: (typed) => { | |
| 42 | if (typed !== full) return `Type ${full} to confirm.`; | |
| 43 | // The prompt pops itself once this returns; the repository's | |
| 44 | // own screens unwind after the delete, never to read it again. | |
| 45 | return (async () => { | |
| 46 | const done = await deleteRepo(f.app, f.user, repo); | |
| 47 | if (!done.ok) { | |
| 48 | ctx.toast(done.error === "not_found" ? "no such repository" : "could not delete the repository", "danger"); | |
| 49 | return; | |
| 50 | } | |
| 51 | ctx.toast(`deleted ${full}`, "success"); | |
| 52 | ctx.leaveRepo(repo.id); | |
| 53 | })(); | |
A single pop leaves a nested screen showing a deleted repository. Unwind to the screen below the repository screen. |
||
| 54 | }, | |
| 55 | }), | |
| 56 | ), | |
| 57 | }, | |
| 58 | ]; | |
| 59 | } |
src/tui/screens/workspace.ts +3
| @@ -130,6 +130,7 @@ import { tellCommands } from "./tell.ts"; | ||
| 130 | 130 | import { workingCommands } from "./working.ts"; |
| 131 | 131 | import { forgeItCommands } from "./forge_it.ts"; |
| 132 | 132 | import { makeItYoursScreen, yoursCommands } from "./yours.ts"; |
| 133 | import { deleteRepoCommands } from "./deleterepo.ts"; | |
| 133 | 134 | import { visionCommands } from "./vision.ts"; |
| 134 | 135 | import { labelsCommands } from "./labels.ts"; |
| 135 | 136 | import { appEnvCommands } from "./appenv.ts"; |
| @@ -1433,6 +1434,7 @@ export function workspaceScreen( | ||
| 1433 | 1434 | }; |
| 1434 | 1435 | |
| 1435 | 1436 | const screen: Screen = { |
| 1437 | ...(repo === undefined ? {} : { repoId: repo.id }), | |
| 1436 | 1438 | commands: () => [ |
| 1437 | 1439 | ...workingCommands(f, ctx, repo), |
| 1438 | 1440 | ...tellCommands(f, ctx, () => screen, repo), |
| @@ -1440,6 +1442,7 @@ export function workspaceScreen( | ||
| 1440 | 1442 | ...reportsCommands(f, ctx, repo), |
| 1441 | 1443 | ...noteCommands(f, ctx, () => screen, repo), |
| 1442 | 1444 | ...yoursCommands(f, ctx, repo), |
| 1445 | ...deleteRepoCommands(f, ctx, repo), | |
| 1443 | 1446 | ...forgeItCommands(f, ctx, repo, () => screen), |
| 1444 | 1447 | ...hooksCommands(f, ctx, repo), |
| 1445 | 1448 | ...visionCommands(f, ctx, repo), |
test/tui-delete-repo.test.ts added +184
| @@ -0,0 +1,184 @@ | ||
| 1 | /** | |
| 2 | * Deleting a repository from the terminal's `:` palette (#8031): an admin who | |
| 3 | * types the full name deletes it; any other text keeps it; a writer who is not | |
| 4 | * an admin, and an agent, are never offered the command. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createRepo, deleteRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 13 | import { canAdmin, canWrite, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import type { Screen } from "../src/tui/app/app.ts"; | |
| 16 | import { style } from "../src/tui/term/style.ts"; | |
| 17 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 18 | import { deleteRepoCommands } from "../src/tui/screens/deleterepo.ts"; | |
| 19 | import { repoScreen } from "../src/tui/screens/repo.ts"; | |
| 20 | import { local } from "../src/tui/screens/where.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | import { settleApp } from "./helpers/tui.ts"; | |
| 24 | import { proveAll } from "./helpers/verified.ts"; | |
| 25 | ||
| 26 | quietLogs(); | |
| 27 | ||
| 28 | let dir: string; | |
| 29 | let app: App; | |
| 30 | let alice: User; | |
| 31 | let bob: User; | |
| 32 | let bot: User; | |
| 33 | ||
| 34 | const KEYS: Record<string, string> = { enter: "\r" }; | |
| 35 | ||
| 36 | /** A screen that says it is beneath the repository's, so a test can see where the unwinding landed. */ | |
| 37 | const below: Screen = { | |
| 38 | title: "below", | |
| 39 | keys: [], | |
| 40 | render: (area, s) => void s.text(area.x, area.y, "BELOW-THE-REPO", style(0, 0), area.x + area.w), | |
| 41 | key: () => false, | |
| 42 | }; | |
| 43 | ||
| 44 | function open(user: User, at: Repo, nested = false) { | |
| 45 | const terminal = headlessTerminal(120, 40); | |
| 46 | const { app: tui, session } = createForgeApp( | |
| 47 | app, | |
| 48 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 49 | { terminal, exitHooks: false }, | |
| 50 | ); | |
| 51 | tui.start(); | |
| 52 | if (nested) tui.ctx.push(below); | |
| 53 | tui.ctx.push(repoScreen(session, tui.ctx, local(session, at))); | |
| 54 | return { | |
| 55 | session, | |
| 56 | ctx: tui.ctx, | |
| 57 | text: () => tui.screen.toText(), | |
| 58 | feed: async (...keys: string[]) => { | |
| 59 | for (const k of keys) { | |
| 60 | terminal.feed(KEYS[k] ?? k); | |
| 61 | await settleApp(tui); | |
| 62 | } | |
| 63 | }, | |
| 64 | stop: () => tui.stop?.(), | |
| 65 | }; | |
| 66 | } | |
| 67 | ||
| 68 | /** Open the delete prompt from the palette once its row has drawn. */ | |
| 69 | async function prompt(h: ReturnType<typeof open>): Promise<void> { | |
| 70 | await h.feed(":", ..."Delete this"); | |
| 71 | for (let i = 0; i < 100 && !h.text().includes("cannot be undone"); i++) | |
| 72 | await new Promise<void>((r) => setTimeout(r, 10)), await h.feed(); | |
| 73 | await h.feed("enter"); | |
| 74 | } | |
| 75 | ||
| 76 | async function mkRepo(owner: string | undefined, name: string): Promise<Repo> { | |
| 77 | const made = await createRepo(app, alice, { ...(owner ? { owner } : {}), name, visibility: "public" }); | |
| 78 | if (!made.ok) throw new Error(String(made.error)); | |
| 79 | return made.value; | |
| 80 | } | |
| 81 | ||
| 82 | beforeAll(async () => { | |
| 83 | dir = await mkdtemp(join(tmpdir(), "forge-tui-delete-repo-")); | |
| 84 | app = createApp({ dataDir: dir, port: 0 }); | |
| 85 | const mk = async (username: string, first = false) => { | |
| 86 | const r = await createUser(app.db, { | |
| 87 | origin: "seed", | |
| 88 | trusted: true, | |
| 89 | username, | |
| 90 | email: `${username}@forge.local`, | |
| 91 | password: "a-good-passphrase", | |
| 92 | adminIfFirst: first, | |
| 93 | }); | |
| 94 | if (!r.ok) throw new Error(r.error); | |
| 95 | return r.value; | |
| 96 | }; | |
| 97 | alice = await mk("alice", true); | |
| 98 | bob = await mk("bob"); | |
| 99 | bot = await mk("crewbot1"); | |
| 100 | // An agent that is also a forge admin: canAdmin accepts it, so only the kind check keeps it out. | |
| 101 | app.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE id = ?1").run(bot.id); | |
| 102 | bot = { ...bot, kind: "agent", isAdmin: true }; | |
| 103 | proveAll(app); | |
| 104 | }); | |
| 105 | afterAll(async () => { | |
| 106 | app.db.close(); | |
| 107 | await rm(dir, { recursive: true, force: true }); | |
| 108 | }); | |
| 109 | ||
| 110 | describe("delete a repository in the terminal", () => { | |
| 111 | test("an admin who types the name deletes the repository and leaves its screen", async () => { | |
| 112 | const repo = await mkRepo(undefined, "doomed"); | |
| 113 | const h = open(alice, repo); | |
| 114 | await prompt(h); | |
| 115 | expect(h.text()).toContain("alice/doomed"); | |
| 116 | await h.feed(..."alice/doomed", "enter"); | |
| 117 | expect(getRepoById(app.db, repo.id)).toBeUndefined(); | |
| 118 | expect(h.text()).toContain("deleted alice/doomed"); | |
| 119 | h.stop(); | |
| 120 | }); | |
| 121 | ||
| 122 | test("a wrong name is refused and the repository stays", async () => { | |
| 123 | const repo = await mkRepo(undefined, "keeper"); | |
| 124 | const h = open(alice, repo); | |
| 125 | await prompt(h); | |
| 126 | await h.feed(..."keeper", "enter"); | |
| 127 | expect(h.text()).toContain("Type alice/keeper to confirm."); | |
| 128 | expect(getRepoById(app.db, repo.id)).toBeDefined(); | |
| 129 | h.stop(); | |
| 130 | }); | |
| 131 | ||
| 132 | test("a writer who is not an admin, and an agent, are not offered it", async () => { | |
| 133 | const org = createOrg(app.db, "acme", alice.id); | |
| 134 | if (!org.ok) throw new Error(org.error); | |
| 135 | const devs = createTeam(app.db, org.value.id, "devs", "write"); | |
| 136 | if (!devs.ok) throw new Error(devs.error); | |
| 137 | addTeamMember(app.db, devs.value, bob.id); | |
| 138 | const shop = await mkRepo("acme", "shop"); | |
| 139 | expect(canWrite(app.db, shop, bob)).toBe(true); | |
| 140 | expect(canAdmin(app.db, shop, bob)).toBe(false); | |
| 141 | expect(canAdmin(app.db, shop, bot)).toBe(true); | |
| 142 | ||
| 143 | for (const who of [bob, bot]) { | |
| 144 | const h = open(who, shop); | |
| 145 | expect(deleteRepoCommands(h.session, h.ctx, shop)).toEqual([]); | |
| 146 | h.stop(); | |
| 147 | } | |
| 148 | const h = open(alice, shop); | |
| 149 | expect(deleteRepoCommands(h.session, h.ctx, shop).map((c) => c.title)).toEqual([ | |
| 150 | "acme/shop — Delete this repository", | |
| 151 | ]); | |
| 152 | h.stop(); | |
| 153 | }); | |
| 154 | ||
| 155 | test("an admin lands on the screen beneath the repository's, which is not read again", async () => { | |
| 156 | const repo = await mkRepo(undefined, "unwound"); | |
| 157 | const h = open(alice, repo, true); | |
| 158 | await prompt(h); | |
| 159 | await h.feed(..."alice/unwound", "enter"); | |
| 160 | expect(getRepoById(app.db, repo.id)).toBeUndefined(); | |
| 161 | expect(h.text()).toContain("BELOW-THE-REPO"); | |
| 162 | h.stop(); | |
| 163 | }); | |
| 164 | ||
| 165 | test("leaving a repository drops a nested screen above its own as well", async () => { | |
| 166 | const repo = await mkRepo(undefined, "nested"); | |
| 167 | const h = open(alice, repo, true); | |
| 168 | h.ctx.push({ ...below, title: "nested", render: (a, s) => void s.text(a.x, a.y, "NESTED-SCREEN", style(0, 0), a.x + a.w) }); | |
| 169 | await h.feed("x"); | |
| 170 | expect(h.text()).toContain("NESTED-SCREEN"); | |
| 171 | h.ctx.leaveRepo(repo.id); | |
| 172 | await h.feed("x"); | |
| 173 | expect(h.text()).toContain("BELOW-THE-REPO"); | |
| 174 | expect(h.text()).not.toContain("NESTED-SCREEN"); | |
| 175 | h.stop(); | |
| 176 | }); | |
| 177 | ||
| 178 | test("deleteRepo refuses a writer who is not an admin and the repository stays", async () => { | |
| 179 | const shop = await mkRepo("acme", "kept"); | |
| 180 | const refused = await deleteRepo(app, bob, shop); | |
| 181 | expect(refused.ok).toBe(false); | |
| 182 | expect(getRepoById(app.db, shop.id)).toBeDefined(); | |
| 183 | }); | |
| 184 | }); |
new above
offline
This kind !== user check is untested (the test passes without it). Make a test that fails without it, or remove the check.