Public: anyone can read this; the crew can change it.
An admin makes a repository private or public from the terminal's About card with `V`, after a confirm that says who will see it #8087
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8008-an-admin-makes-a-repository-private-or-p → main 2 commits fork 1 thread · 1 unresolved
3 files
changed +211
−1
README.md +1
| @@ -455,6 +455,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 455 | 455 | - [](docs/SENTINEL.md) — the forge hunting its own faults; [](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`). |
| 456 | 456 | - The `flow` label: ready issues labelled `flow` go first (`readyNow` in `src/core/dependencies.ts` is the one place); when none is ready, builders take other ready work as usual. There is no switch and no state. A writer puts an issue first with the **Flow first** button on the issue and on each row of its list (`POST …/issues/:number/flow`), with `f` on the terminal issue screen, or with MCP `labels` and `action: "add"` (all three make the label on first use, for a writer only); the map's "Ready now" shows labelled work on top, marked `flow`. |
| 457 | 457 | - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`. |
| 458 | - Visibility at the terminal: on the About card (`I`) a repository admin who is a person sees `V make private` on a public repository and `V make public` on a private one. `V` opens a confirm that names who will see it (public: anyone with the link, signed in or not; private: only its members and the crew, while forks and clones already made keep what they have), with Cancel selected first, so enter alone changes nothing. Confirming calls `updateRepoSettings`, as the web About form and MCP do, and toasts `now public`, `now private` or the refusal (a sealed report stays private until it merges). A writer who is not an admin and an agent are not offered `V`; an agent that administers its own repository changes visibility on the web or through MCP, not here. | |
| 458 | 459 | - Prompt source at the terminal: `i` on the Deploys screen opens the app's Instagram prompt source, as Settings › Prompts does, and is offered only to the repository's admin who is a person (an agent, a reader or a guest sees no `i` and it does nothing). It lists the account, when it last ran and what it said, and when the token is refreshed again. `a` connects one (the token typed masked, then the hour in UTC; it is never drawn or logged), `f` fetches now and says what came back, `d` removes it after a `y/n` confirm. A second `f` while a fetch is running is ignored. Refusals are the page's words (`invalid_token`, `invalid_at`, `no_such_source`, `not_running`, and `already_connected` only when an account was connected on the web while the screen was open: the screen offers `a` only when there is none). |
| 459 | 460 | - Stop running as an app at the terminal: `X` on the Deploys screen, offered only to the repository's admin who is a person while it is an app (a writer who is not an admin, an agent, a reader or a guest sees no `X` and it does nothing). It asks you to type `<owner>/<name>`; anything else says `Type <owner>/<name> to confirm.` and changes nothing. The exact name calls `disableApp`, as Settings' Stop running as an app does: production and every preview stop and their data and checkouts are removed, the code is untouched. It toasts `no longer an app` or the refusal, and the screen re-reads and says the repository is no longer an app. |
| 460 | 461 | - Events at the terminal: `v` on the About card (`I`) lists the repository's events newest first, 50 at a time; `n` loads the next page, `s` keeps only settings changes (the same `kind=repo.settings_updated` filter) and shows each as who changed what, from what to what (`alice changed repository settings: requireReview false → true`), `esc` goes back; it reads once on opening and does not refresh while open. The audience is `GET …/events`'s and MCP `events`' (a webhook's URL and an app's variable names are the admins'). |
src/tui/screens/about.ts +45 −1
| @@ -221,6 +221,10 @@ export function aboutScreen( | ||
| 221 | 221 | binding(["f"], checking() ? "stop following upstream" : "follow upstream"), |
| 222 | 222 | ] |
| 223 | 223 | : []), |
| 224 | // Who can read it is a person's, an admin's call (#8008). | |
| 225 | ...(mayPerson() | |
| 226 | ? [binding(["V"], repo?.visibility === "public" ? "make private" : "make public")] | |
| 227 | : []), | |
| 224 | 228 | ...(mayDetach() && detachJob(repo!.id)?.phase !== "repacking" |
| 225 | 229 | ? [binding(["x"], "detach from parent")] |
| 226 | 230 | : []), |
| @@ -263,7 +267,8 @@ export function aboutScreen( | ||
| 263 | 267 | }); |
| 264 | 268 | const lead = inner.x + 1; |
| 265 | 269 | const edge = inner.x + inner.w; |
| 266 | const visibility = facts?.visibility ?? repo?.visibility ?? "public"; | |
| 270 | // The row first: `V` changes it under facts read before the card opened. | |
| 271 | const visibility = repo?.visibility ?? facts?.visibility ?? "public"; | |
| 267 | 272 | let x = badge(s, inner.x, inner.y, c, { |
| 268 | 273 | text: visibility, |
| 269 | 274 | variant: visibility === "public" ? "success" : "secondary", |
| @@ -421,6 +426,45 @@ export function aboutScreen( | ||
| 421 | 426 | }); |
| 422 | 427 | return true; |
| 423 | 428 | } |
| 429 | if (e.key === "V" && repo !== undefined && mayPerson()) { | |
| 430 | const at = repo; | |
| 431 | const visibility = at.visibility === "public" ? "private" : "public"; | |
| 432 | const name = `${at.ownerName}/${at.name}`; | |
| 433 | ctx.push( | |
| 434 | confirmScreen(ctx, screen, { | |
| 435 | title: visibility === "public" ? "Make public" : "Make private", | |
| 436 | body: wrap( | |
| 437 | visibility === "public" | |
| 438 | ? `Make ${name} public? Anyone with the link can read its code, issues and pulls, signed in or not.` | |
| 439 | : `Make ${name} private? Only its members and the crew can read it from now on; forks and clones already made keep what they have.`, | |
| 440 | 52, | |
| 441 | ), | |
| 442 | actions: [ | |
| 443 | { label: visibility === "public" ? "Make public" : "Make private" }, | |
| 444 | { label: "Cancel" }, | |
| 445 | ], | |
| 446 | selected: 1, | |
| 447 | onChoose: (i) => { | |
| 448 | if (i !== 0) return; | |
| 449 | void updateRepoSettings(f.app, f.user, at, { visibility }).then((done) => { | |
| 450 | if (done.ok) { | |
| 451 | repo = getRepoById(f.db, done.value.id) ?? done.value; | |
| 452 | rows = factRows(); | |
| 453 | ctx.toast(`now ${visibility}`, "success"); | |
| 454 | } else | |
| 455 | ctx.toast( | |
| 456 | at.sealedFor !== null | |
| 457 | ? "a sealed report stays private until it merges" | |
| 458 | : "that could not be changed", | |
| 459 | "danger", | |
| 460 | ); | |
| 461 | ctx.invalidate(); | |
| 462 | }); | |
| 463 | }, | |
| 464 | }), | |
| 465 | ); | |
| 466 | return true; | |
| 467 | } | |
| 424 | 468 | if (e.key === "y" && cloneUrl !== "") { |
| 425 | 469 | ctx.copy(cloneUrl); |
| 426 | 470 | ctx.toast("copied the clone URL", "success"); |
test/tui-about-visibility.test.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * Visibility at the terminal's door (#8008): on the About card, an admin who | |
| 3 | * is a person makes the repository private or public with `V`, and only the | |
| 4 | * confirm (Cancel selected first) changes it. A writer who is not an admin and | |
| 5 | * an agent admin are not offered it. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 14 | import { canAdmin, canWrite, createRepo, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import type { Ctx, Screen } from "../src/tui/app/app.ts"; | |
| 17 | import { aboutScreen } from "../src/tui/screens/about.ts"; | |
| 18 | import { Screen as Surface } from "../src/tui/term/screen.ts"; | |
| 19 | import { theme } from "../src/tui/ui/theme.ts"; | |
| 20 | import { localSource } from "../src/view/local.ts"; | |
| 21 | import type { Session } from "../src/tui/session.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | let dataDir: string; | |
| 27 | let app: App; | |
| 28 | let founder: User; | |
| 29 | let writer: User; | |
| 30 | let bot: User; | |
| 31 | let repo: Repo; | |
| 32 | let botRepo: Repo; | |
| 33 | ||
| 34 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 35 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 36 | return r.value; | |
| 37 | }; | |
| 38 | async function until(what: string, done: () => boolean, ms = 5000): Promise<void> { | |
| 39 | const end = Date.now() + ms; | |
| 40 | while (!done()) { | |
| 41 | if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); | |
| 42 | await Bun.sleep(10); | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | function card(user: User, target: Repo = repo) { | |
| 47 | const pushed: Screen[] = []; | |
| 48 | const toasts: string[] = []; | |
| 49 | const ctx = { | |
| 50 | theme: theme("dark"), | |
| 51 | invalidate() {}, | |
| 52 | toast: (t: string) => toasts.push(t), | |
| 53 | push: (s: Screen) => pushed.push(s), | |
| 54 | pop() {}, | |
| 55 | copy() {}, | |
| 56 | hit() {}, | |
| 57 | } as unknown as Ctx; | |
| 58 | const f = { app, db: app.db, user } as unknown as Session; | |
| 59 | const src = localSource(app, getRepoById(app.db, target.id)!, user); | |
| 60 | const screen = aboutScreen(f, ctx, { render() {} } as unknown as Screen, src, undefined, "main"); | |
| 61 | const label = (key: string) => screen.keys.find((b) => b.keys.includes(key))?.help; | |
| 62 | const press = (key: string) => screen.key!({ key } as never); | |
| 63 | return { pushed, toasts, label, press }; | |
| 64 | } | |
| 65 | const visibility = () => getRepoById(app.db, repo.id)!.visibility; | |
| 66 | ||
| 67 | beforeAll(async () => { | |
| 68 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-visibility-")); | |
| 69 | app = createApp({ dataDir, port: 0 }); | |
| 70 | const make = async (username: string, adminIfFirst = false) => | |
| 71 | ok( | |
| 72 | await createUser(app.db, { | |
| 73 | origin: "seed", | |
| 74 | trusted: true, | |
| 75 | username, | |
| 76 | email: `${username}@forge.local`, | |
| 77 | password: "a-good-passphrase", | |
| 78 | adminIfFirst, | |
| 79 | }), | |
| 80 | ); | |
| 81 | const root = await make("founding", true); | |
| 82 | founder = await make("founder"); | |
| 83 | writer = await make("writer"); | |
| 84 | const org = ok(createOrg(app.db, "acme", founder.id)); | |
| 85 | ok(await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "site", visibility: "public" })); | |
| 86 | repo = getRepoById(app.db, (app.db.query("SELECT id FROM repo WHERE name = 'site'").get() as { id: string }).id)!; | |
| 87 | addTeamMember(app.db, ok(createTeam(app.db, org.id, "Writers", "write")), writer.id); | |
| 88 | bot = ok(await createAgent(app, root, { username: "bot" })).user; | |
| 89 | // An agent never sits on an admin team; it administers what it owns. | |
| 90 | botRepo = ok(await createRepo(app.db, app.reposRoot, { ownerId: bot.id, name: "own", visibility: "public" })); | |
| 91 | }, 30_000); | |
| 92 | ||
| 93 | afterAll(async () => { | |
| 94 | app?.close(); | |
| 95 | await rm(dataDir, { recursive: true, force: true }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("an admin makes a public repository private with V then confirm, and public again", async () => { | |
| 99 | const c = card(founder); | |
| 100 | expect(c.label("V")).toBe("make private"); | |
| 101 | c.press("V"); | |
| 102 | expect(c.pushed).toHaveLength(1); | |
| 103 | const confirm = c.pushed[0]!; | |
| 104 | // Cancel is selected first: enter alone changes nothing. | |
| 105 | confirm.key!({ key: "enter" } as never); | |
| 106 | await Bun.sleep(20); | |
| 107 | expect(visibility()).toBe("public"); | |
| 108 | ||
| 109 | c.press("V"); | |
| 110 | c.pushed[1]!.key!({ key: "left" } as never); | |
| 111 | c.pushed[1]!.key!({ key: "enter" } as never); | |
| 112 | await until("private", () => visibility() === "private"); | |
| 113 | await until("the toast", () => c.toasts.includes("now private")); | |
| 114 | expect(c.label("V")).toBe("make public"); | |
| 115 | ||
| 116 | c.press("V"); | |
| 117 | c.pushed[2]!.key!({ key: "left" } as never); | |
| 118 | c.pushed[2]!.key!({ key: "enter" } as never); | |
| 119 | await until("public", () => visibility() === "public"); | |
| 120 | expect(c.toasts).toContain("now public"); | |
| 121 | }); | |
| 122 | ||
| 123 | test("a writer who is not an admin and an agent admin are not offered V, and it does nothing", async () => { | |
| 124 | const fresh = getRepoById(app.db, repo.id)!; | |
| 125 | expect(canWrite(app.db, fresh, writer)).toBe(true); | |
| 126 | expect(canAdmin(app.db, fresh, writer)).toBe(false); | |
| 127 | expect(canAdmin(app.db, botRepo, bot)).toBe(true); | |
| 128 | for (const [who, at] of [[writer, repo], [bot, botRepo]] as const) { | |
| 129 | const c = card(who, at); | |
| 130 | expect(c.label("V")).toBeUndefined(); | |
| 131 | c.press("V"); | |
| 132 | expect(c.pushed).toHaveLength(0); | |
| 133 | } | |
| 134 | expect(visibility()).toBe("public"); | |
| 135 | expect(getRepoById(app.db, botRepo.id)!.visibility).toBe("public"); | |
| 136 | }); | |
| 137 | ||
| 138 | test("the confirm says who will see it", () => { | |
| 139 | const c = card(founder); | |
| 140 | c.press("V"); | |
| 141 | const s = new Surface(160, 30); | |
| 142 | c.pushed[0]!.render({ x: 0, y: 0, w: 160, h: 30 }, s); | |
| 143 | const text = s.toText(); | |
| 144 | expect(c.pushed[0]!.title).toBe("Make private"); | |
| 145 | // The sentence wraps inside the dialog: each of its lines is drawn whole. | |
| 146 | for (const line of [ | |
| 147 | "Make acme/site private? Only its members and the", | |
| 148 | "crew can read it from now on; forks and clones", | |
| 149 | "already made keep what they have.", | |
| 150 | ]) | |
| 151 | expect(text).toContain(line); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a sealed report is refused public, says why, and stays private", async () => { | |
| 155 | const sealed = ok(await createRepo(app.db, app.reposRoot, { ownerId: founder.id, name: "report", visibility: "private" })); | |
| 156 | app.db.query("UPDATE repo SET sealed_for = ?1 WHERE id = ?2").run(repo.id, sealed.id); | |
| 157 | const c = card(founder, sealed); | |
| 158 | expect(c.label("V")).toBe("make public"); | |
| 159 | c.press("V"); | |
| 160 | c.pushed[0]!.key!({ key: "left" } as never); | |
| 161 | c.pushed[0]!.key!({ key: "enter" } as never); | |
| 162 | await until("the refusal", () => c.toasts.length > 0); | |
| 163 | expect(c.toasts).toEqual(["a sealed report stays private until it merges"]); | |
| 164 | expect(getRepoById(app.db, sealed.id)!.visibility).toBe("private"); | |
| 165 | }); |
new above
offline
The key is added here but README.md does not document it. Add a paragraph next to the
done at README.md:457.