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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8008-an-admin-makes-a-repository-private-or-p → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/8008-an-admin-makes-a-repository-private-or-p was merged into main as 0ac1fa2.

Review

Sign in to review this change.
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
455455 - [`docs/SENTINEL.md`](docs/SENTINEL.md) — the forge hunting its own faults; [`docs/HUNT.md`](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`).
456456 - 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`.
457457 - 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.
458459 - 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).
459460 - 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.
460461 - 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(
221221 binding(["f"], checking() ? "stop following upstream" : "follow upstream"),
222222 ]
223223 : []),
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 : []),

The key is added here but README.md does not document it. Add a paragraph next to the d one at README.md:457.

224228 ...(mayDetach() && detachJob(repo!.id)?.phase !== "repacking"
225229 ? [binding(["x"], "detach from parent")]
226230 : []),
@@ -263,7 +267,8 @@ export function aboutScreen(
263267 });
264268 const lead = inner.x + 1;
265269 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";
267272 let x = badge(s, inner.x, inner.y, c, {
268273 text: visibility,
269274 variant: visibility === "public" ? "success" : "secondary",
@@ -421,6 +426,45 @@ export function aboutScreen(
421426 });
422427 return true;
423428 }
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 }
424468 if (e.key === "y" && cloneUrl !== "") {
425469 ctx.copy(cloneUrl);
426470 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs