Public: anyone can read this; the crew can change it.
An admin sets the repository's description from the terminal's About card with `w`, as Settings does #8265
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8066-an-admin-sets-the-repository-s-descripti → main 2 commits fork 2 threads · 2 unresolved
3 files
changed +187
−1
README.md +1
| @@ -507,6 +507,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 507 | 507 | - 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`. |
| 508 | 508 | - 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`. |
| 509 | 509 | - 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. |
| 510 | - Description at the terminal: on the About card (`I`) a repository admin who is a person sees `w write description`. `w` opens a one-line prompt filled with the current description; enter saves it through `updateRepoSettings` (as the web form and MCP `repo_settings` do, so it is audited the same way) and toasts `description saved`, esc changes nothing, and an empty line clears it. Control characters and more than 2000 characters are refused in a toast and nothing is written. That cap and the control-character rule live in the terminal only: the web form and the API accept any text. A reader and an agent are not offered `w`, and pressing it does nothing. | |
| 510 | 511 | - 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). |
| 511 | 512 | - 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. |
| 512 | 513 | - Everyday account at the terminal: a forge admin with no choice made sees a muted line at the head of the dashboard asking, as the web home card does. `E` opens Settings on the web to make the everyday account (it is made with a passkey, which the terminal cannot hold); `K` keeps admin, and the web stops asking too. Once linked, the line names the everyday account. Anyone else, and an agent, sees neither key. |
src/tui/screens/about.ts +40 −1
| @@ -71,7 +71,7 @@ import { style } from "../term/style.ts"; | ||
| 71 | 71 | import { truncate, wrap } from "../term/width.ts"; |
| 72 | 72 | import { badge, card } from "../ui/widgets.ts"; |
| 73 | 73 | import type { Session } from "../session.ts"; |
| 74 | import { PIN_MARK, ago, confirmScreen, drawFacts, plural } from "./common.ts"; | |
| 74 | import { PIN_MARK, ago, confirmScreen, drawFacts, plural, promptScreen } from "./common.ts"; | |
| 75 | 75 | import { connectionsScreen, egressScreen } from "./egress.ts"; |
| 76 | 76 | import { eventsScreen } from "./events.ts"; |
| 77 | 77 | import { newPullScreen } from "./newpull.ts"; |
| @@ -197,6 +197,7 @@ export function aboutScreen( | ||
| 197 | 197 | ), |
| 198 | 198 | ] |
| 199 | 199 | : []), |
| 200 | ...(mayPerson() ? [binding(["w"], "write description")] : []), | |
| 200 | 201 | ...(mayOutsiders() |
| 201 | 202 | ? [ |
| 202 | 203 | binding( |
| @@ -484,6 +485,34 @@ export function aboutScreen( | ||
| 484 | 485 | }); |
| 485 | 486 | return true; |
| 486 | 487 | } |
| 488 | if (e.key === "w" && repo !== undefined && mayPerson()) { | |
| 489 | const at = repo; | |
| 490 | ctx.push( | |
| 491 | promptScreen(ctx, screen, { | |
| 492 | title: "Description", | |
| 493 | label: "What this repository is for", | |
| 494 | value: at.description, | |
| 495 | hint: "one line; empty clears it", | |
| 496 | onSubmit: (value) => { | |
| 497 | const refusal = descriptionRefusal(value); | |
| 498 | if (refusal !== undefined) { | |
| 499 | ctx.toast(refusal, "danger"); | |
| 500 | return refusal; | |
| 501 | } | |
| 502 | void updateRepoSettings(f.app, f.user, at, { description: value }).then((done) => { | |
| 503 | if (done.ok) { | |
| 504 | repo = getRepoById(f.db, done.value.id) ?? done.value; | |
| 505 | rows = factRows(); | |
| 506 | ctx.toast("description saved", "success"); | |
| 507 | } else ctx.toast("that could not be changed", "danger"); | |
| 508 | ctx.invalidate(); | |
| 509 | }); | |
| 510 | return undefined; | |
| 511 | }, | |
| 512 | }), | |
| 513 | ); | |
| 514 | return true; | |
| 515 | } | |
| 487 | 516 | if (e.key === "t" && repo !== undefined && mayTrail()) { |
| 488 | 517 | const shown = getProvenance(f.db, repo.id)?.shown === true; |
| 489 | 518 | const done = showProvenance(f.app, f.user, repo, !shown); |
| @@ -661,6 +690,16 @@ export function detachRows(repo: Repo | undefined): [string, string][] { | ||
| 661 | 690 | return words === null ? [] : [["detach", words]]; |
| 662 | 691 | } |
| 663 | 692 | |
| 693 | /** The longest description the MCP `repo_settings` tool takes; Settings has no cap of its own. */ | |
| 694 | const DESCRIPTION_MAX = 2000; | |
| 695 | ||
| 696 | /** Why a typed description is refused, in words, or nothing when it stands. */ | |
| 697 | function descriptionRefusal(value: string): string | undefined { | |
| 698 | if (/[\u0000-\u001f\u007f-\u009f]/.test(value)) return "a description is one line of plain text"; | |
| 699 | if ([...value].length > DESCRIPTION_MAX) return `a description is ${DESCRIPTION_MAX} characters at most`; | |
outdated
The control-character branch has no test. Add one that would fail without it. |
||
| 700 | return undefined; | |
| 701 | } | |
| 702 | ||
| 664 | 703 | /** One line when an admin has put the repository away. */ |
| 665 | 704 | function archivedRow(repo: Repo | undefined): [string, string][] { |
| 666 | 705 | if (repo === undefined || repo.archivedAt === null) return []; |
test/tui-about-description.test.ts added +146
| @@ -0,0 +1,146 @@ | ||
| 1 | /** | |
| 2 | * The description at the terminal's door (#8066): `w` on the About card writes it | |
| 3 | * through updateRepoSettings, for an admin who is a person only. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createAgent } from "../src/actions/crew.ts"; | |
| 11 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 12 | import { createRepo, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import type { Ctx, Screen } from "../src/tui/app/app.ts"; | |
| 15 | import { aboutScreen } from "../src/tui/screens/about.ts"; | |
| 16 | import { theme } from "../src/tui/ui/theme.ts"; | |
| 17 | import { localSource } from "../src/view/local.ts"; | |
| 18 | import type { Session } from "../src/tui/session.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | let dataDir: string; | |
| 24 | let app: App; | |
| 25 | let founder: User; | |
| 26 | let writer: User; | |
| 27 | let bot: User; | |
| 28 | let repo: Repo; | |
| 29 | let botRepo: Repo; | |
| 30 | ||
| 31 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 32 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 33 | return r.value; | |
| 34 | }; | |
| 35 | async function until(what: string, done: () => boolean, ms = 5000): Promise<void> { | |
| 36 | const end = Date.now() + ms; | |
| 37 | while (!done()) { | |
| 38 | if (Date.now() > end) throw new Error(`timed out waiting for ${what}`); | |
| 39 | await Bun.sleep(10); | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | function card(user: User, target: Repo = repo) { | |
| 44 | const pushed: Screen[] = []; | |
| 45 | const toasts: string[] = []; | |
| 46 | const ctx = { | |
| 47 | theme: theme("dark"), | |
| 48 | invalidate() {}, | |
| 49 | toast: (t: string) => toasts.push(t), | |
| 50 | push: (s: Screen) => pushed.push(s), | |
| 51 | pop() {}, | |
| 52 | copy() {}, | |
| 53 | hit() {}, | |
| 54 | } as unknown as Ctx; | |
| 55 | const f = { app, db: app.db, user } as unknown as Session; | |
| 56 | const src = localSource(app, getRepoById(app.db, target.id)!, user); | |
| 57 | const screen = aboutScreen(f, ctx, { render() {} } as unknown as Screen, src, undefined, "main"); | |
| 58 | const label = (key: string) => screen.keys.find((b) => b.keys.includes(key))?.help; | |
| 59 | const press = (key: string) => screen.key!({ key } as never); | |
| 60 | return { pushed, toasts, label, press }; | |
| 61 | } | |
| 62 | const desc = () => getRepoById(app.db, repo.id)!.description; | |
| 63 | const typed = (c: ReturnType<typeof card>, text: string) => { | |
| 64 | for (const ch of text) c.pushed.at(-1)!.key!({ key: ch } as never); | |
| 65 | }; | |
| 66 | const trail = () => | |
| 67 | (app.db.query("SELECT payload FROM event WHERE kind = 'repo.settings_updated' AND repo_id = ?1").all(repo.id) as { payload: string }[]).map((r) => r.payload); | |
| 68 | ||
| 69 | beforeAll(async () => { | |
| 70 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-description-")); | |
| 71 | app = createApp({ dataDir, port: 0 }); | |
| 72 | const make = async (username: string, adminIfFirst = false) => | |
| 73 | ok( | |
| 74 | await createUser(app.db, { | |
| 75 | origin: "seed", | |
| 76 | trusted: true, | |
| 77 | username, | |
| 78 | email: `${username}@forge.local`, | |
| 79 | password: "a-good-passphrase", | |
| 80 | adminIfFirst, | |
| 81 | }), | |
| 82 | ); | |
| 83 | const root = await make("founding", true); | |
| 84 | founder = await make("founder"); | |
| 85 | writer = await make("writer"); | |
| 86 | const org = ok(createOrg(app.db, "acme", founder.id)); | |
| 87 | ok(await createRepo(app.db, app.reposRoot, { ownerId: org.id, name: "site", visibility: "public", description: "old" })); | |
| 88 | repo = getRepoById(app.db, (app.db.query("SELECT id FROM repo WHERE name = 'site'").get() as { id: string }).id)!; | |
| 89 | addTeamMember(app.db, ok(createTeam(app.db, org.id, "Writers", "write")), writer.id); | |
| 90 | bot = ok(await createAgent(app, root, { username: "bot" })).user; | |
| 91 | botRepo = ok(await createRepo(app.db, app.reposRoot, { ownerId: bot.id, name: "own", visibility: "public" })); | |
| 92 | }, 30_000); | |
| 93 | ||
| 94 | afterAll(async () => { | |
| 95 | app?.close(); | |
| 96 | await rm(dataDir, { recursive: true, force: true }); | |
| 97 | }); | |
| 98 | ||
| 99 | test("an admin's w, text and enter change the row and the audit trail", async () => { | |
| 100 | const c = card(founder); | |
| 101 | expect(c.label("w")).toBe("write description"); | |
| 102 | c.press("w"); | |
| 103 | typed(c, "!"); | |
| 104 | c.pushed[0]!.key!({ key: "enter" } as never); | |
| 105 | await until("saved", () => desc() === "old!"); | |
| 106 | await until("the toast", () => c.toasts.includes("description saved")); | |
| 107 | expect(trail().some((p) => p.includes('"description"') && p.includes("old!"))).toBe(true); | |
| 108 | }); | |
| 109 | ||
| 110 | test("esc leaves it as it was", async () => { | |
| 111 | const c = card(founder); | |
| 112 | c.press("w"); | |
| 113 | typed(c, "zzz"); | |
| 114 | c.pushed[0]!.key!({ key: "esc" } as never); | |
| 115 | await Bun.sleep(20); | |
| 116 | expect(desc()).toBe("old!"); | |
| 117 | }); | |
| 118 | ||
| 119 | test("a refusal is a toast and the description stays", async () => { | |
| 120 | const c = card(founder); | |
| 121 | c.press("w"); | |
| 122 | typed(c, "x".repeat(2000)); | |
| 123 | c.pushed[0]!.key!({ key: "enter" } as never); | |
| 124 | await Bun.sleep(20); | |
| 125 | expect(c.toasts.some((t) => t.includes("2000 characters"))).toBe(true); | |
| 126 | expect(desc()).toBe("old!"); | |
| 127 | }); | |
| 128 | ||
| 129 | test("a control character is refused with a toast and the description stays", async () => { | |
| 130 | const c = card(founder); | |
| 131 | c.press("w"); | |
| 132 | typed(c, "bad\u0007bell"); | |
| 133 | c.pushed[0]!.key!({ key: "enter" } as never); | |
| 134 | await Bun.sleep(20); | |
| 135 | expect(c.toasts).toContain("a description is one line of plain text"); | |
| 136 | expect(desc()).toBe("old!"); | |
| 137 | }); | |
| 138 | ||
| 139 | test("a reader and an agent admin are not offered w, and it does nothing", () => { | |
| 140 | for (const [who, at] of [[writer, repo], [bot, botRepo]] as const) { | |
| 141 | const c = card(who, at); | |
| 142 | expect(c.label("w")).toBeUndefined(); | |
| 143 | c.press("w"); | |
| 144 | expect(c.pushed).toHaveLength(0); | |
| 145 | } | |
| 146 | }); |
new above
offline
The comment on line 693 ("One line when an admin has put the repository away.") now sits above DESCRIPTION_MAX instead of archivedRow. Move the new constant and descriptionRefusal above that comment or elsewhere.