Public: anyone can read this; the crew can change it.

The terminal's deploys screen lets a writer see who a private app names with `m`, and add or remove one after a plan, as MCP `app_members` does #7434

Merged opened this

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

Merged

torque/forge:torque/7418-the-terminal-s-deploys-screen-lets-a-wri → main 4 commits fork 7 threads · 7 unresolved

  • Merged. torque/7418-the-terminal-s-deploys-screen-lets-a-wri was merged into main as 60aca0f.

Review

Sign in to review this change.
4 files changed +406 −1
docs/APPS.md +6 −1
@@ -180,7 +180,12 @@ Under the box, four starter sentences are plain links (no script):
180180 gate; a guest sees neither. A writer presses `p` there to probe the running app's
181181 doors as nobody, a user and an admin, read-only — one line per door with
182182 its status and `ok` or what was wrong, `r` to knock again; the result is
183 kept and the mismatch issue opened or closed as for MCP `probe`.
183 kept and the mismatch issue opened or closed as for MCP `probe`. On a
184 private app, `m` lists the people it names; a writer who is a person
185 presses `a` to let someone in by username or `d` to let the selected one
186 out, each shown as a plan and applied after a confirm, as MCP
outdated

Update this once agents are no longer offered m, for example: 'An agent is not offered m; the list is its writers' to choose.'

187 `app_members` does. An agent is not offered `m`; the list is its writers'
188 to choose.
184189 - A **private** app answers only to someone the forge has vouched for
185190 (`canRead` on the repository, then a signed grant); a public one to anyone.
186191 - **Every app has a terminal.** `ssh -p 2222 <name>@<forge>` runs the app's
src/tui/screens/appmembers.ts added +177
@@ -0,0 +1,177 @@
1 /**
2 * The people a private app names, in the terminal (#7418): `m` on the
3 * deploys screen. The list is read through `readAppMembers`, the settings
4 * page's and MCP `app_members`'s action, so it is gated the same way: a
5 * person who can write the repository, never an agent — and the deploys
6 * screen offers `m` to that person alone.
outdated

This says an agent is 'offered no key', but deploys.ts offers it m. Make the code and the comment agree. Not offering m at all is the fix.

7 *
8 * `a` asks for a username and `d` takes out the person under the cursor.
9 * Each is planned first (`setAppMembersThrough` with `dryRun`), the plan —
10 * who is let in, who is let out — is shown in a confirm, and only the
11 * confirm applies it — to the list as it stands then, so a change another
12 * writer made meanwhile is kept. An unknown username is refused in the action's words
13 * and the prompt stays open.
14 */
15
16 import {
17 readAppMembers,
18 setAppMembersThrough,
19 type AppMembersError,
20 } from "../../actions/app_members.ts";
21 import { SETTING_WORDS } from "../../actions/setting_words.ts";
22 import type { Repo } from "../../core/repos.ts";
23 import type { Ctx, Screen } from "../app/app.ts";
24 import { binding } from "../app/keys.ts";
25 import { style } from "../term/style.ts";
26 import { wrap } from "../term/width.ts";
27 import { fill, len, split } from "../ui/layout.ts";
28 import type { Session } from "../session.ts";
29 import {
30 ListState,
31 confirmScreen,
32 drawList,
33 later,
34 promptScreen,
35 type ListRow,
36 } from "./common.ts";
37
38 /** A refusal in MCP `app_members`'s words. */
39 export function appMembersRefusal(e: AppMembersError): string {
40 return e.kind === "invalid"
41 ? e.message
42 : e.kind === "forbidden" || e.kind === "unauthorized"
43 ? SETTING_WORDS.appMembersForbidden
44 : "no such app or repository (owner/name)";
45 }
46
47 /** `Let in: carol` / `Let out: dave`, one line each side that moves. */
48 export function planLines(plan: { added: readonly string[]; removed: readonly string[] }): string[] {
49 return [
50 ...(plan.added.length > 0 ? [`Let in: ${plan.added.join(", ")}`] : []),
51 ...(plan.removed.length > 0 ? [`Let out: ${plan.removed.join(", ")}`] : []),
52 ];
53 }
54
55 export function appMembersScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
56 const state = new ListState();
57 let members: readonly string[] = [];
58 // Whether the action reads for this viewer; it decides, not the screen.
59 let canChange = false;
60 let refusal = "";
61 let note = "";
62 const reload = (): void => {
63 const read = readAppMembers(f.app, f.user, repo);
64 canChange = read.ok;
65 members = read.ok ? read.value.members : [];
66 refusal = read.ok ? "" : appMembersRefusal(read.error);
67 state.clamp(members.length);
68 ctx.invalidate();
69 };
70 reload();
71 const say = (text: string): void => {
72 note = text;
73 reload();
74 };
75
76 /**
77 * The list as it stands now with one person's change made — never the
78 * screen's snapshot, so a change another writer made meanwhile (the page,
79 * the API, MCP) is kept; `undefined` when it cannot be read.
80 */
81 const current = (): readonly string[] | undefined => {
82 const read = readAppMembers(f.app, f.user, repo);
83 return read.ok ? read.value.members : undefined;
84 };
85 type Change = (now: readonly string[]) => readonly string[];
86 const letIn = (who: string): Change => (now) => [...now, who];
87 const letOut = (who: string): Change => (now) => now.filter((m) => m !== who);
88
89 /** Plan `change`, or the refusal; on a plan, confirm and apply it to the list as it is then. */
90 const propose = (title: string, verb: string, change: Change): string | undefined => {

Next: the already-named case (empty plan) has no test. Add one: a, carol for someone already named shows the note and opens no confirm.

91 const plan = setAppMembersThrough(f.app, f.user, repo, { members: change(current() ?? members), dryRun: true });
92 if (!plan.ok) return appMembersRefusal(plan.error);
93 const lines = planLines(plan.value);
outdated

High: this writes next, the full list computed from the snapshot taken before the confirm. A change another writer made in between (on the page, the API or MCP) is silently reverted. Re-read here and apply only this one person's delta to the current list, and test it with a concurrent change.

94 if (lines.length === 0) {
95 say("Nobody is let in or out: the list already says so.");
96 return undefined;
97 }
98 void later(ctx, () =>
99 confirmScreen(ctx, screen, {
100 title,
101 body: [...lines, `${repo.ownerName}/${repo.name} and its previews.`],
102 actions: [{ label: "Cancel", variant: "ghost" }, { label: verb }],
103 selected: 1,
104 onChoose: (i) => {
105 if (i !== 1) return;
106 // Read again: the confirm may have stood open while the list moved.
107 const now = readAppMembers(f.app, f.user, repo);
108 if (!now.ok) return say(appMembersRefusal(now.error));
109 const set = setAppMembersThrough(f.app, f.user, repo, { members: change(now.value.members), dryRun: false });
110 say(set.ok ? `${planLines(set.value).join(" · ") || "Nobody moved"}.` : appMembersRefusal(set.error));
111 },
112 }),
113 );
114 return undefined;
outdated

Same snapshot problem as line 93: build the change as a delta (who is let out), not as members.filter(...) of a list that may already be stale.

115 };
116
117 const add = (): Screen =>
118 promptScreen(ctx, screen, {
119 title: "Let someone in",
120 label: "their username",
121 placeholder: "carol",
122 onSubmit: (name) => {
123 const who = name.trim();
124 if (who === "") return "Name a person.";
125 return propose("Let in", "Let in", letIn(who));
126 },
127 });
128
129 const remove = (who: string): void => {
130 const refused = propose("Let out", "Let out", letOut(who));
131 if (refused !== undefined) say(refused);
132 };
133
134 const screen: Screen = {
135 title: "Members",
136 get keys() {
137 return [
138 binding(["j", "k"], "move"),
139 ...(canChange ? [binding(["a"], "let someone in"), binding(["d"], "let them out")] : []),
140 binding(["esc"], "back"),
141 ];
142 },
143 render(area, s) {
144 const c = ctx.theme.c;
145 const text =
146 note !== ""
147 ? note
148 : refusal !== ""
149 ? refusal
150 : members.length === 0
151 ? `${repo.ownerName}/${repo.name} names nobody; its writers and crew enter. Press a to let someone in.`
152 : `${repo.ownerName}/${repo.name} names ${members.length}: they enter the app and its previews, nothing else.`;
153 const said = wrap(text, Math.max(1, area.w)).slice(0, 3);
154 const [head, list] = split(area, "v", [len(said.length), fill(1)]);
155 said.forEach((line, i) =>
156 s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w),
157 );
158 const rows: ListRow[] = members.map((m) => ({ title: m }));
159 drawList(s, list!, c, state, rows);
160 },
161 key(e) {
162 if (state.key(e, members.length)) return true;
163 if (e.ctrl || e.alt || !canChange) return false;
164 if (e.key === "a") {
165 ctx.push(add());
166 return true;
167 }
168 const who = members[state.index];
169 if (e.key === "d" && who !== undefined) {
170 remove(who);
171 return true;
172 }
173 return false;
174 },
175 };
176 return screen;
177 }
src/tui/screens/deploys.ts +18
@@ -20,6 +20,11 @@
2020 * `probeApp` against the running production app, every door tried as
2121 * nobody, a user and an admin, read-only, one line per door.
2222 *
23 * A private app's writer who is a person — never an agent — gets `m`: the
24 * people the app names, with `a` and `d` to let one in or out after a plan
25 * and a confirm, as MCP `app_members` does (`appmembers.ts`). A public app
26 * names nobody and is not offered `m`.
27 *
2328 * The app's admin sees its own domains under the head, each with its state
2429 * in the settings page's words (`domain_words.ts`), and gets `n`: a domains
2530 * screen where `+` adds one, `c` checks the selected one's DNS proof now and
@@ -83,6 +88,7 @@ import {
8388 type ListRow,
8489 } from "./common.ts";
8590 import { RunList, openRunLog, runRows } from "./runlist.ts";
91 import { appMembersScreen } from "./appmembers.ts";
8692
8793 const LIMIT = 50;
8894
@@ -132,6 +138,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
132138 let side: string[] = [];
133139 let pulls: AppPullLine[] = [];
134140 let production: Production = { state: "", redeploy: false, stop: false };
141 let members = false;
135142 /** The app's domains, or undefined for anyone `listAppDomains` refuses. */
136143 let domains: AppDomainView[] | undefined;
137144
@@ -149,6 +156,12 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
149156 side = appSideLines(f.app, f.user, repo);
150157 pulls = appPullLines(f.app, f.user, repo);
151158 production = readProduction(f, repo);
159 // The action's gate: a person who can write, never an agent.
160 members =
161 getAppByRepo(f.db, repo.id)?.access === "private" &&
162 f.user !== undefined &&
163 f.user.kind !== "agent" &&
164 canWrite(f.db, repo, f.user);
152165 const listed = listAppDomains(f.app, f.user, repo);
153166 domains = listed.ok ? listed.value : undefined;
154167 loaded = true;
@@ -204,6 +217,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
204217 ...(production.redeploy ? [binding(["D"], "redeploy production")] : []),
205218 ...(production.stop ? [binding(["S"], "stop production")] : []),
206219 ...(mayProbe ? [binding(["p"], "probe the running app's doors")] : []),
220 ...(members ? [binding(["m"], "members")] : []),
207221 ...(domains !== undefined ? [binding(["n"], "the app's own domains")] : []),
208222 ];
209223 },
@@ -296,6 +310,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
296310 ctx.push(probeScreen(f, ctx, repo));
297311 return true;
298312 }
313 if (e.key === "m" && members) {
314 ctx.push(appMembersScreen(f, ctx, repo));
315 return true;
316 }
299317 if (e.key === "n" && domains !== undefined) {
300318 ctx.push(domainsScreen(f, ctx, repo, read));
301319 return true;
test/tui-appmembers.test.ts added +205
@@ -0,0 +1,205 @@
1 /**
2 * The deploys screen's `m` (#7418): the people a private app names, from the
3 * terminal. A writer lets carol in with `a`, sees the plan, confirms, and
4 * carol may enter the app; `d` and a confirm let her out, and she may not.
5 * Cancelling a plan changes nothing. An unknown username is refused in MCP
6 * `app_members`'s words. A confirm applies its own change to the list as it
7 * stands then, so a change another writer made meanwhile is kept. An agent
8 * on the crew is not offered `m`. A public app has no `m`.
9 */
10
11 import { afterAll, beforeAll, expect, test } from "bun:test";
12 import { mkdtemp, rm } from "node:fs/promises";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { setAppMembersThrough } from "../src/actions/app_members.ts";
16 import { addToCrew, createAgent } from "../src/actions/crew.ts";
17 import { createRepo } from "../src/actions/repos.ts";
18 import { createApp, type App } from "../src/app.ts";
19 import { createApp as createAppRecord, type AppRecord } from "../src/core/apps.ts";
20 import { canEnterApp, type Repo } from "../src/core/repos.ts";
21 import { createUser, type User } from "../src/core/users.ts";
22 import { createForgeApp } from "../src/tui/forge.ts";
23 import { planLines } from "../src/tui/screens/appmembers.ts";
24 import { deploysScreen } from "../src/tui/screens/deploys.ts";
25 import { headlessTerminal } from "../src/tui/term/terminal.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27 import { settleApp } from "./helpers/tui.ts";
28
29 quietLogs();
30
31 let dir: string;
32 let app: App;
33 let alice: User;
34 let carol: User;
35 let dave: User;
36 let bot: User;
37 let shop: Repo;
38 let open: Repo;
39 let record: AppRecord;
40
41 const LEFT = "\x1b[D";
42
43 beforeAll(async () => {
44 dir = await mkdtemp(join(tmpdir(), "forge-tui-appmembers-"));
45 app = createApp({ dataDir: dir, port: 0 });
46 const mk = async (username: string, first = false): Promise<User> => {
47 const r = await createUser(app.db, {
48 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
49 password: "a-good-passphrase", adminIfFirst: first,
50 });
51 if (!r.ok) throw new Error(r.error);
52 return r.value;
53 };
54 alice = await mk("alice", true);
55 carol = await mk("carol");
56 dave = await mk("dave");
57 const agent = await createAgent(app, alice, { username: "bot" });
58 if (!agent.ok) throw new Error(String(agent.error));
59 bot = agent.value.user;
60 const repoOf = async (name: string): Promise<Repo> => {
61 const r = await createRepo(app, alice, { name, visibility: "public" });
62 if (!r.ok) throw new Error("repo failed");
63 return r.value;
64 };
65 shop = await repoOf("shop");
66 open = await repoOf("open");
67 const made = createAppRecord(app.db, { repoId: shop.id, slug: "shop", access: "private" });
68 if (!made.ok) throw new Error(made.error);
69 record = made.value;
70 if (!createAppRecord(app.db, { repoId: open.id, slug: "open", access: "public" }).ok) throw new Error("app");
71 expect(addToCrew(app, alice, shop, "bot").ok).toBe(true);
72 });
73
74 afterAll(async () => {
75 app.db.close();
76 await rm(dir, { recursive: true, force: true });
77 });
78
79 /** The deploys screen of `of` as `who`, fed `keys` one chunk at a time; every frame. */
80 async function drive(who: User, of: Repo, keys: readonly (string | (() => void))[]): Promise<string[]> {
81 const terminal = headlessTerminal(160, 40);
82 const { app: tui, session } = createForgeApp(
83 app,
84 { sshUsername: who.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: who },
85 { terminal, exitHooks: false },
86 );
87 tui.start();
88 await settleApp(tui);
89 tui.push(deploysScreen(session, tui.ctx, of));
90 await settleApp(tui);
91 const frames = [tui.screen.toText()];
92 for (const k of keys) {
93 // A function is someone else acting between two keys.
94 if (typeof k === "function") k();
95 else terminal.feed(k);
96 await settleApp(tui);
97 frames.push(tui.screen.toText());
98 }
99 tui.stop();
100 return frames;
101 }
102
103 const enters = (u: User): boolean => canEnterApp(app.db, shop, record, u);
104
105 test("a private app's writer is offered `m members` on the deploys screen", async () => {
106 const [first] = await drive(alice, shop, []);
107 expect(first).toContain("m members");
108 });
109
110 test("`a` plans letting carol in, and only the confirm lets her in", async () => {
111 expect(enters(carol)).toBe(false);
112 const frames = await drive(alice, shop, ["m", "a", "carol", "\r"]);
113 // The plan, shown before anything changed.
114 expect(frames.at(-1)).toContain("Let in: carol");
115 expect(enters(carol)).toBe(false);
116 // Cancel is one step left of the chosen action.
117 await drive(alice, shop, ["m", "a", "carol", "\r", LEFT, "\r"]);
118 expect(enters(carol)).toBe(false);
119 const done = await drive(alice, shop, ["m", "a", "carol", "\r", "\r"]);
120 expect(enters(carol)).toBe(true);
121 expect(done.at(-1)).toContain("Let in: carol.");
122 expect(done.at(-1)).toContain("carol");
123 });
124
125 test("`d` plans letting the selected person out, and the confirm does", async () => {
126 expect(enters(carol)).toBe(true);
127 const frames = await drive(alice, shop, ["m", "d"]);
128 expect(frames.at(-1)).toContain("Let out: carol");
129 expect(enters(carol)).toBe(true);
130 const done = await drive(alice, shop, ["m", "d", "\r"]);
131 expect(enters(carol)).toBe(false);
132 expect(done.at(-1)).toContain("Let out: carol.");
133 });
134
135 test("an unknown username is refused in MCP `app_members`'s words, and nobody is let in", async () => {
136 const frames = await drive(alice, shop, ["m", "a", "nobody-here", "\r"]);
137 const refused = setAppMembersThrough(app, alice, shop, { members: ["nobody-here"] });
138 expect(refused.ok).toBe(false);
139 const words = !refused.ok && refused.error.kind === "invalid" ? refused.error.message : "";
140 expect(words).toBe("nobody-here is not a person's account here");
141 expect(frames.at(-1)).toContain(words);
142 expect(frames.at(-1)).not.toContain("Let in:");
143 // The prompt stays open, holding what was typed, to be corrected.
144 expect(frames.at(-1)).toContain("Let someone in");
145 expect(frames.at(-1)).toContain("their username");
146 });
147
148 test("letting in someone already named says so and opens no confirm", async () => {
149 expect(setAppMembersThrough(app, alice, shop, { members: ["carol"], dryRun: false }).ok).toBe(true);
150 const frames = await drive(alice, shop, ["m", "a", "carol", "\r"]);
151 expect(frames.at(-1)).toContain("Nobody is let in or out: the list already says so.");
outdated

Next: the d here removes whichever row is first. Assert the screen lists carol first, or set the list to just carol before driving, so a change in member ordering cannot flake this.

152 expect(frames.at(-1)).not.toContain("Let in:");
153 expect(frames.at(-1)).not.toContain("Cancel");
154 expect(enters(carol)).toBe(true);
155 });
156
157 test("a confirm applies only its own change: a person another writer named meanwhile stays named", async () => {
158 expect(setAppMembersThrough(app, alice, shop, { members: [], dryRun: false }).ok).toBe(true);
159 // Alice plans carol in; while the confirm stands open, dave is named on the page.
160 const nameDave = (): void => {
161 expect(setAppMembersThrough(app, alice, shop, { members: ["dave"], dryRun: false }).ok).toBe(true);
162 };
163 await drive(alice, shop, ["m", "a", "carol", "\r", nameDave, "\r"]);
164 expect(enters(carol)).toBe(true);
165 expect(enters(dave)).toBe(true);
166 // Alice plans carol out; meanwhile dave is let out elsewhere, and the
167 // confirm does not name him again from the screen's old list.
168 const dropDave = (): void => {
169 expect(setAppMembersThrough(app, alice, shop, { members: ["carol"], dryRun: false }).ok).toBe(true);
170 };
171 const frames = await drive(alice, shop, ["m", "d", dropDave, "\r"]);
172 // The row `d` took was carol's, whatever order the list is in: the plan names her alone.
173 expect(frames[2]).toContain("Let out: carol");
174 expect(frames[2]).not.toContain("Let out: carol, dave");
175 expect(frames[2]).not.toContain("Let out: dave");
176 expect(enters(carol)).toBe(false);
177 expect(enters(dave)).toBe(false);
178 });
179
180 test("an agent on the crew is not offered `m`, and pressing it opens nothing", async () => {
181 expect(setAppMembersThrough(app, alice, shop, { members: ["carol"], dryRun: false }).ok).toBe(true);
182 const frames = await drive(bot, shop, ["m", "a", "d"]);
183 expect(frames[0]).toContain("production deploys");
184 expect(frames[0]).not.toContain("m members");
185 for (const frame of frames.slice(1)) {
186 expect(frame).toContain("production deploys");
187 expect(frame).not.toContain("Members");
188 expect(frame).not.toContain("Let in:");
189 expect(frame).not.toContain("Let out:");
190 }
191 expect(enters(carol)).toBe(true);
192 });
193
194 test("a public app is not offered `m`, and pressing it opens nothing", async () => {
195 const frames = await drive(alice, open, ["m"]);
196 expect(frames[0]).not.toContain("m members");
197 expect(frames.at(-1)).not.toContain("names nobody");
198 expect(frames.at(-1)).toContain("production deploys");
199 });
200
201 test("the plan says each side that moves, and only those", () => {
202 expect(planLines({ added: ["carol"], removed: [] })).toEqual(["Let in: carol"]);
203 expect(planLines({ added: [], removed: ["dave", "erin"] })).toEqual(["Let out: dave, erin"]);
204 expect(planLines({ added: [], removed: [] })).toEqual([]);
205 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs