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

The terminal lists your connected origins, syncs one now with `s` and disconnects one with `d` after a confirm, as Settings › Origins does #8010

Merged opened this

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

Merged

scout/forge:scout/8000-the-terminal-lists-your-connected-origin → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/8000-the-terminal-lists-your-connected-origin was merged into main as 9cb0080.

Review

Sign in to review this change.
9 files changed +361 −9
README.md +2
@@ -73,6 +73,8 @@ In the terminal, `T` on a shipped pull's screen takes it back, as the pull page'
7373
7474 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.
7575
76 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`.
77
7678 A writer puts a ready issue **First**: the dispatcher starts it before the rest, and the map's Ready now list (and the plan screen's) says `first, by <who>`. On the map page it is the First button on each ready row (a same-origin POST to `/:owner/:name/issues/:number/first`, no JavaScript; `Accept: application/json` gets JSON); in the terminal it is `f` on a ready line of the plan screen. Both call `putIssueFirst` (`src/actions/first.ts`). Refused, changing nothing: a reader or an agent ("Only someone with write access can put an issue first."), a signed-out post (sent to sign in), a read-only token, a cross-origin post, and an issue that is closed or still waits for another ("Only an open issue that nothing waits in front of can go first."). The rank ends when the issue closes, and it is an `issue.first` event, so open maps and plan screens refresh. No MCP tool yet.
7779
7880 A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three rounds) has "Give back to the crew": one press assigns the pull, and the open issue it closes if a person holds it, to the agent that opened it, writes "Given back to the crew by @who", and the card leaves; the next tick starts its run. It is for a writer of the repository, and a pull a person opened, or by an agent gone from the crew, gets no button. In the terminal, `b` on a handed-over row of the On you pane does the same (`src/actions/give_back.ts`). The repository's planner can do it by comment: one that starts "Planner, one more round" on a handed-over crew pull emits the assignment to its builder, which starts the run (the pull is already the builder's, so nothing changes hands). It is the planner's alone; a reader's, the reviewer's or any other agent's does nothing. It is an agent's word, not a director's, so it does not end the hand-over: the card stays on `/asks` and the pull still counts as handed over during the round.
docs/ORIGINS.md +7 −1
@@ -87,7 +87,13 @@ minted the child's token may let it run: **Let it run here** on the pull page,
8787 never counts toward `require_review` or the merge gate, a new push needs a new
8888 grant, and a proposed pull never lands on its own, even on an autonomous repo.
8989
90 Connect one at **Settings → Origins** (or `A` then `o` in the terminal). Which
90 Connect one at **Settings → Origins** (or `A` then `o` in the terminal). The
91 terminal lists the ones you have connected under the palette's **Connected
92 origins** (a signed-in person's, never an agent's or a guest's): `j`/`k` move,
93 `s` syncs the selected origin now and says how many repositories it found (or
94 the report's error), `d` asks first and then disconnects it, as Settings →
95 Origins does (`syncOriginNow`, `disconnectOrigin`; another person's origin id
96 is "No such origin."). Which
9197 way in you get is a property of the forge, not a preference:
9298
9399 - **GitHub** — a device code. The forge shows a short code and a URL; you type
src/actions/origin_words.ts added +18
@@ -0,0 +1,18 @@
1 /**
2 * What an origin's sync and disconnect say, in one place so the page in
3 * Settings › Origins and the terminal's Connected origins say the same thing.
4 */
5
6 export const NO_SUCH_ORIGIN = "No such origin.";
7
8 export const syncedWords = (repos: number): string =>
9 `Synced ${repos} repositor${repos === 1 ? "y" : "ies"}.`;
10
11 export const disconnectedWords = (revokedUpstream: boolean): string =>
12 revokedUpstream
13 ? "Disconnected, and the grant was revoked upstream."
14 : "Disconnected. Revoke the token on the origin too if you want it dead there.";
15
16 /** What the confirm page tells you before a disconnect. */
17 export const DISCONNECT_DETAIL =
18 "The stored credential is destroyed here immediately, and the repositories mirroring it stop syncing — they keep their git objects, issues and comments, and go on being readable as ordinary local repositories. The grant is revoked upstream only where the forge allows it (GitHub, and only when this forge has a client secret configured); everywhere else revoke the token on the origin yourself. The message after this says which happened.";
src/tui/screens/connections.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * Connected origins in the terminal: the hosts your mirrors come from, as
3 * Settings › Origins lists them. `s` syncs the selected one now, `d` disconnects
4 * it after a confirm — through `actions/origins.ts`, in `origin_words.ts`'s words.
5 */
6
7 import { disconnectOrigin, listUserOrigins, syncOriginNow } from "../../actions/origins.ts";
8 import { DISCONNECT_DETAIL, NO_SUCH_ORIGIN, disconnectedWords, syncedWords } from "../../actions/origin_words.ts";
9 import { mirrorsByOrigin } from "../../core/origins.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import { style } from "../term/style.ts";
13 import { truncate } from "../term/width.ts";
14 import type { Session } from "../session.ts";
15 import { fill, len, split } from "../ui/layout.ts";
16 import { ListState, confirmScreen, drawList } from "./common.ts";
17 import type { PaletteEntry } from "./entries.ts";
18
19 const host = (webUrl: string): string => URL.parse(webUrl)?.host ?? webUrl;
20 const day = (at: string | null): string => (at === null ? "never synced" : `synced ${at.slice(0, 16).replace("T", " ")}`);
21
22 export function connectionsScreen(f: Session, ctx: Ctx): Screen {
23 const state = new ListState();
24 const origins = () => (f.user === undefined ? [] : listUserOrigins(f.app, f.user));
25 const counts = () => (f.user === undefined ? new Map<string, { count: number }>() : mirrorsByOrigin(f.app.db, f.user.id));
26
27 const screen: Screen = {
28 title: "Connected origins",
29 keys: [binding(["j", "k"], "move cursor"), binding(["s"], "sync now"), binding(["d"], "disconnect"), binding(["esc"], "back")],
30 render(area, s) {
31 const c = ctx.theme.c;
32 const [head, list] = split(area, "v", [len(1), fill(1)]);
33 const all = origins();
34 const text = all.length === 0 ? "No origin connected. Connect one in Settings › Origins." : "The hosts your mirrors come from.";
35 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
36 const n = counts();
37 drawList(
38 s,
39 list!,
40 c,
41 state,
42 all.map((o) => {
43 const k = n.get(o.id)?.count ?? 0;
44 return {
45 title: `${host(o.webUrl)} · ${o.login}`,
46 ...(o.lastError !== "" ? { tone: "danger" as const } : {}),
47 secondary: `${k} repositor${k === 1 ? "y" : "ies"} · ${day(o.lastSyncAt)}`,
48 meta: o.lastError !== "" ? o.lastError : o.authKind === "oauth" ? "OAuth" : "token",
49 };
50 }),
51 );
52 },
53 key(e) {
54 if (state.key(e, origins().length)) return true;
55 if (e.ctrl || e.alt || f.user === undefined) return false;
56 const o = origins()[state.index];
57 if (o === undefined) return false;
58 const user = f.user;
59 if (e.key === "s") {
60 ctx.toast(`Syncing ${host(o.webUrl)}…`);
61 void syncOriginNow(f.app, user, o.id)
62 .then((r) => {
63 if (!r.ok) ctx.toast(NO_SUCH_ORIGIN, "danger");
64 else if (r.value.error !== "") ctx.toast(r.value.error, "danger");
65 else ctx.toast(syncedWords(r.value.repos), "success");
66 ctx.invalidate();
67 })
68 .catch(() => ctx.toast("The sync failed.", "danger"));

The s path is untested. Add a test for the success, error and not-found toasts.

69 return true;
70 }
71 if (e.key === "d") {
72 ctx.push(
73 confirmScreen(ctx, screen, {
74 title: "Disconnect this origin?",
75 body: [`${o.login} at ${host(o.webUrl)}`, "", DISCONNECT_DETAIL],
76 actions: [
77 { label: "Cancel", variant: "ghost" },
78 { label: "Disconnect", variant: "danger" },
79 ],
80 width: 80,
81 onChoose: (choice) => {
82 if (choice !== 1) return;
83 void disconnectOrigin(f.app, user, o.id)
84 .then((r) => {
85 ctx.toast(r.ok ? disconnectedWords(r.value.revokedUpstream) : NO_SUCH_ORIGIN, r.ok ? "success" : "danger");
86 ctx.invalidate();
87 })
88 .catch(() => ctx.toast("The disconnect failed.", "danger"));
89 },
90 }),
91 );
92 return true;
93 }
94 return false;
95 },
96 };
97 return screen;
98 }
99
100 /** Connected origins: a signed-in person, not an agent. */
101 export const entry: PaletteEntry = {

The agent exclusion is untested; assert that an agent identity is not offered this entry.

102 id: "origins",
103 title: "Connected origins",
104 hint: "the hosts your mirrors come from",
105 offered: (session) => session.user !== undefined && session.user.kind !== "agent",
106 run: (session, ctx) => ctx.push(connectionsScreen(session, ctx)),
107 };
src/tui/screens/entries.ts +2
@@ -14,6 +14,7 @@ import { entry as audit } from "./audit.ts";
1414 import { entry as boards } from "./boards.ts";
1515 import { entry as bulk } from "./bulk.ts";
1616 import { entry as cloud } from "./cloud.ts";
17 import { entry as connections } from "./connections.ts";
1718 import { entry as crewHandover } from "./crewhandover.ts";
1819 import { entry as crewSplits } from "./crewsplits.ts";
1920 import { entry as domains } from "./domains.ts";
@@ -73,6 +74,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
7374 bulk,
7475 subscriptions,
7576 cloud,
77 connections,
7678 domains,
7779 front,
7880 owner,
src/web/pages/origins.ts +9 −6
@@ -29,6 +29,11 @@ import {
2929 syncOriginNow,
3030 type Reattachment,
3131 } from "../../actions/origins.ts";
32 import {
33 NO_SUCH_ORIGIN,
34 disconnectedWords,
35 syncedWords,
36 } from "../../actions/origin_words.ts";
3237 import {
3338 editableHosts,
3439 envHosts,
@@ -1069,13 +1074,13 @@ export async function originSyncSubmit(ctx: Ctx): Promise<Response> {
10691074 if (!synced.ok)
10701075 return flashTo(ctx, "/settings/origins", {
10711076 tone: "danger",
1072 message: "No such origin.",
1077 message: NO_SUCH_ORIGIN,
10731078 });
10741079 const report = synced.value;
10751080 return flashTo(ctx, "/settings/origins", {
10761081 ...(report.error === ""
10771082 ? {
1078 message: `Synced ${report.repos} repositor${report.repos === 1 ? "y" : "ies"}.`,
1083 message: syncedWords(report.repos),
10791084 }
10801085 : { tone: "danger" as const, message: report.error }),
10811086 });
@@ -1096,12 +1101,10 @@ export async function originDisconnect(ctx: Ctx): Promise<Response> {
10961101 if (!gone.ok)
10971102 return flashTo(ctx, back, {
10981103 tone: "danger",
1099 message: "No such origin.",
1104 message: NO_SUCH_ORIGIN,
11001105 });
11011106 return flashTo(ctx, back, {
1102 message: gone.value.revokedUpstream
1103 ? "Disconnected, and the grant was revoked upstream."
1104 : "Disconnected. Revoke the token on the origin too if you want it dead there.",
1107 message: disconnectedWords(gone.value.revokedUpstream),
11051108 });
11061109 }
11071110
src/web/pages/search.ts +2 −2
@@ -10,6 +10,7 @@
1010 * that was never meant to be confirmed.
1111 */
1212
13 import { DISCONNECT_DETAIL } from "../../actions/origin_words.ts";
1314 import { strangerCodeSearch } from "../../http/strangers.ts";
1415 import { search } from "../../actions/search.ts";
1516 import { envsFor } from "../../apps/runtime.ts";
@@ -619,8 +620,7 @@ const DESTRUCTIVE: {
619620 describe: () => ({
620621 verb: "Disconnect origin",
621622 title: "Disconnect this origin?",
622 detail:
623 "The stored credential is destroyed here immediately, and the repositories mirroring it stop syncing — they keep their git objects, issues and comments, and go on being readable as ordinary local repositories. The grant is revoked upstream only where the forge allows it (GitHub, and only when this forge has a client secret configured); everywhere else revoke the token on the origin yourself. The message after this says which happened.",
623 detail: DISCONNECT_DETAIL,
624624 phrase: "",
625625 }),
626626 },
test/palette-roster.test.ts +6
@@ -104,6 +104,7 @@ test("a site-admin person is offered the whole roster", () => {
104104 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
105105 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
106106 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
107 ["origins", "Connected origins", "the hosts your mirrors come from"],
107108 ["domains", "Failure domains", "failure domains and their last drill"],
108109 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
109110 ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"],
@@ -160,6 +161,10 @@ test("a site-admin agent is offered the roster without the person-only entries",
160161 ]);
161162 });
162163
164 test("no agent is offered Connected origins", () => {
165 expect(roster({ ...admin, kind: "agent" }).map(([id]) => id)).not.toContain("origins");
166 });
167
163168 test("a member person is offered the signed-in roster without the admin entries", () => {
164169 expect(roster(member)).toEqual([
165170 ["home", "Home", "your repositories"],
@@ -193,6 +198,7 @@ test("a member person is offered the signed-in roster without the admin entries"
193198 ["can", "What I can do", "your role and what it allows here"],
194199 ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"],
195200 ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"],
201 ["origins", "Connected origins", "the hosts your mirrors come from"],
196202 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
197203 ["family", "Family", "the forge that sent this one, and the forges it sent"],
198204 ["chain", "Chain", "the forges this forge's code descends from, root first"],
test/tui-origins.test.ts added +208
@@ -0,0 +1,208 @@
1 /** Connected origins in the terminal: listed, disconnected after a confirm, never offered to a guest. */
2
3 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App as Forge } from "../src/app.ts";
8 import { syncOriginNow, disconnectOrigin } from "../src/actions/origins.ts";
9 import { NO_SUCH_ORIGIN } from "../src/actions/origin_words.ts";
10 import { createOrigin, listOrigins } from "../src/core/origins.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import type { TuiIdentity } from "../src/tui/session.ts";
14 import { headlessTerminal } from "../src/tui/term/terminal.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 describe("connected origins in the terminal", () => {
21 let dataDir: string;
22 let forge: Forge;
23 let aliceId = "";
24
25 function boot(identity: TuiIdentity) {
26 const terminal = headlessTerminal(140, 32);
27 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
28 app.start();
29 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
30 return {
31 text: () => app.screen.toText(),
32 press: async (...keys: string[]) => {
33 keys.forEach((k) => key(k));
34 await settleApp(app);
35 },
36 ctrl: async (k: string) => {
37 key(k, true);
38 await settleApp(app);
39 },
40 type: async (t: string) => {
41 for (const ch of t) key(ch);
42 await settleApp(app);
43 },
44 stop: () => app.stop(),
45 };
46 }
47
48 const open = async (h: ReturnType<typeof boot>) => {
49 await h.ctrl("k");
50 await h.type("Connected origins");
51 return h;
52 };
53
54 beforeAll(async () => {
55 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-origins-"));
56 forge = createApp({ dataDir, port: 0 });
57 const made = await createUser(forge.db, {
58 origin: "seed",
59 trusted: true,
60 username: "alice",
61 email: "[email protected]",
62 password: "a-good-passphrase",
63 adminIfFirst: true,
64 });
65 if (!made.ok) throw new Error(made.error);
66 aliceId = made.value.id;
67 const o = createOrigin(forge.db, dataDir, {
68 userId: aliceId,
69 kind: "gitea",
70 apiUrl: "https://git.example.test/api/v1",
71 webUrl: "https://git.example.test",
72 login: "alicia",
73 token: "t0ken-not-shown",
74 });
75 if (!o.ok) throw new Error(o.error);
76 });
77 afterAll(async () => {
78 forge.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 const alice = () => ({ sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, "alice")! }) as TuiIdentity;
83
84 test("the list shows the seeded origin, and never its token", async () => {
85 const h = await open(boot(alice()));
86 try {
87 await h.press("enter");
88 expect(h.text()).toContain("git.example.test");
89 expect(h.text()).toContain("alicia");
90 expect(h.text()).toContain("0 repositories");
91 expect(h.text()).toContain("never synced");
92 expect(h.text()).not.toContain("t0ken-not-shown");
93 } finally {
94 h.stop();
95 }
96 });
97
98 test("s syncs the selected origin and says how many repositories it found", async () => {
99 const made = await createUser(forge.db, {
100 origin: "seed",
101 trusted: true,
102 username: "carol",
103 email: "[email protected]",
104 password: "a-good-passphrase",
105 });
106 if (!made.ok) throw new Error(made.error);
107 const o = createOrigin(forge.db, dataDir, {
108 userId: made.value.id,
109 kind: "gitea",
110 apiUrl: "http://127.0.0.1:9/api/v1",
111 webUrl: "http://127.0.0.1:9",
112 login: "carol",
113 token: "t0ken-not-shown",
114 });
115 if (!o.ok) throw new Error(o.error);
116 const h = boot({ sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, "carol")! } as TuiIdentity);
117 try {
118 await h.ctrl("k");
119 await h.type("Connected origins");
120 await h.press("enter");
121 expect(listOrigins(forge.db, made.value.id)[0]!.lastError).toBe("");
122 await h.press("s");
123 for (let i = 0; i < 40 && listOrigins(forge.db, made.value.id)[0]!.lastSyncAt === null; i++) await Bun.sleep(100);
124 await h.press("k");
125 expect(listOrigins(forge.db, made.value.id)[0]!.lastSyncAt).not.toBeNull();
126 expect(h.text()).toContain("Synced 0 repositories.");
127 expect(h.text()).not.toContain("never synced");
128 } finally {
129 h.stop();
130 }
131 });
132
133 test("s with no origin does nothing", async () => {
134 const made = await createUser(forge.db, {
135 origin: "seed",
136 trusted: true,
137 username: "bob",
138 email: "[email protected]",
139 password: "a-good-passphrase",
140 });
141 if (!made.ok) throw new Error(made.error);
142 const h = await open(boot({ sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(forge.db, "bob")! } as TuiIdentity));
143 try {
144 await h.press("enter");
145 expect(h.text()).toContain("No origin connected");
146 await h.press("s");
147 expect(h.text()).not.toContain("Syncing");
148 } finally {
149 h.stop();
150 }
151 });
152
153 test("another user's origin id is not found, to sync or to disconnect", async () => {
154 const bob = getUserByName(forge.db, "bob")!;
155 const id = listOrigins(forge.db, aliceId)[0]!.id;
156 const synced = await syncOriginNow(forge, bob, id);
157 expect(synced.ok).toBe(false);
158 if (!synced.ok) expect(synced.error).toBe("not_found");
159 const gone = await disconnectOrigin(forge, bob, id);
160 expect(gone.ok).toBe(false);
161 expect(NO_SUCH_ORIGIN).toBe("No such origin.");
162 expect(listOrigins(forge.db, aliceId)).toHaveLength(1);
163 });
164
165 test("an agent is not offered the entry", async () => {
166 const h = boot({ ...alice(), user: { ...getUserByName(forge.db, "alice")!, kind: "agent" } } as TuiIdentity);
167 try {
168 await h.ctrl("k");
169 await h.type("Connected origins");
170 expect(h.text()).not.toContain("the hosts your mirrors come from");
171 } finally {
172 h.stop();
173 }
174 });
175
176 test("d then Cancel keeps the origin; d then Disconnect removes it", async () => {
177 const h = await open(boot(alice()));
178 try {
179 await h.press("enter");
180 await h.press("d");
181 expect(h.text()).toContain("Disconnect this origin?");
182 expect(h.text()).toContain("Cancel");
183 await h.press("enter");
184 expect(listOrigins(forge.db, aliceId)).toHaveLength(1);
185 expect(h.text()).toContain("git.example.test");
186
187 await h.press("d");
188 await h.press("right");
189 await h.press("enter");
190 expect(listOrigins(forge.db, aliceId)).toHaveLength(0);
191 expect(h.text()).toContain("Disconnected. Revoke the token on the origin too");
192 expect(h.text()).not.toContain("alicia");
193 } finally {
194 h.stop();
195 }
196 });
197
198 test("a guest is not offered the entry", async () => {
199 const h = boot({ sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:unknown" });
200 try {
201 await h.ctrl("k");
202 await h.type("Connected origins");
203 expect(h.text()).not.toContain("the hosts your mirrors come from");
204 } finally {
205 h.stop();
206 }
207 });
208 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs