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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8000-the-terminal-lists-your-connected-origin → main 3 commits fork 2 threads · 2 unresolved
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' | ||
| 73 | 73 | |
| 74 | 74 | 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. |
| 75 | 75 | |
| 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 | ||
| 76 | 78 | 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. |
| 77 | 79 | |
| 78 | 80 | 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, | ||
| 87 | 87 | never counts toward `require_review` or the merge gate, a new push needs a new |
| 88 | 88 | grant, and a proposed pull never lands on its own, even on an autonomous repo. |
| 89 | 89 | |
| 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 | |
| 91 | 97 | way in you get is a property of the forge, not a preference: |
| 92 | 98 | |
| 93 | 99 | - **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")); | |
| 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"; | ||
| 14 | 14 | import { entry as boards } from "./boards.ts"; |
| 15 | 15 | import { entry as bulk } from "./bulk.ts"; |
| 16 | 16 | import { entry as cloud } from "./cloud.ts"; |
| 17 | import { entry as connections } from "./connections.ts"; | |
| 17 | 18 | import { entry as crewHandover } from "./crewhandover.ts"; |
| 18 | 19 | import { entry as crewSplits } from "./crewsplits.ts"; |
| 19 | 20 | import { entry as domains } from "./domains.ts"; |
| @@ -73,6 +74,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 73 | 74 | bulk, |
| 74 | 75 | subscriptions, |
| 75 | 76 | cloud, |
| 77 | connections, | |
| 76 | 78 | domains, |
| 77 | 79 | front, |
| 78 | 80 | owner, |
src/web/pages/origins.ts +9 −6
| @@ -29,6 +29,11 @@ import { | ||
| 29 | 29 | syncOriginNow, |
| 30 | 30 | type Reattachment, |
| 31 | 31 | } from "../../actions/origins.ts"; |
| 32 | import { | |
| 33 | NO_SUCH_ORIGIN, | |
| 34 | disconnectedWords, | |
| 35 | syncedWords, | |
| 36 | } from "../../actions/origin_words.ts"; | |
| 32 | 37 | import { |
| 33 | 38 | editableHosts, |
| 34 | 39 | envHosts, |
| @@ -1069,13 +1074,13 @@ export async function originSyncSubmit(ctx: Ctx): Promise<Response> { | ||
| 1069 | 1074 | if (!synced.ok) |
| 1070 | 1075 | return flashTo(ctx, "/settings/origins", { |
| 1071 | 1076 | tone: "danger", |
| 1072 | message: "No such origin.", | |
| 1077 | message: NO_SUCH_ORIGIN, | |
| 1073 | 1078 | }); |
| 1074 | 1079 | const report = synced.value; |
| 1075 | 1080 | return flashTo(ctx, "/settings/origins", { |
| 1076 | 1081 | ...(report.error === "" |
| 1077 | 1082 | ? { |
| 1078 | message: `Synced ${report.repos} repositor${report.repos === 1 ? "y" : "ies"}.`, | |
| 1083 | message: syncedWords(report.repos), | |
| 1079 | 1084 | } |
| 1080 | 1085 | : { tone: "danger" as const, message: report.error }), |
| 1081 | 1086 | }); |
| @@ -1096,12 +1101,10 @@ export async function originDisconnect(ctx: Ctx): Promise<Response> { | ||
| 1096 | 1101 | if (!gone.ok) |
| 1097 | 1102 | return flashTo(ctx, back, { |
| 1098 | 1103 | tone: "danger", |
| 1099 | message: "No such origin.", | |
| 1104 | message: NO_SUCH_ORIGIN, | |
| 1100 | 1105 | }); |
| 1101 | 1106 | 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), | |
| 1105 | 1108 | }); |
| 1106 | 1109 | } |
| 1107 | 1110 |
src/web/pages/search.ts +2 −2
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * that was never meant to be confirmed. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { DISCONNECT_DETAIL } from "../../actions/origin_words.ts"; | |
| 13 | 14 | import { strangerCodeSearch } from "../../http/strangers.ts"; |
| 14 | 15 | import { search } from "../../actions/search.ts"; |
| 15 | 16 | import { envsFor } from "../../apps/runtime.ts"; |
| @@ -619,8 +620,7 @@ const DESTRUCTIVE: { | ||
| 619 | 620 | describe: () => ({ |
| 620 | 621 | verb: "Disconnect origin", |
| 621 | 622 | 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, | |
| 624 | 624 | phrase: "", |
| 625 | 625 | }), |
| 626 | 626 | }, |
test/palette-roster.test.ts +6
| @@ -104,6 +104,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 104 | 104 | ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"], |
| 105 | 105 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 106 | 106 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 107 | ["origins", "Connected origins", "the hosts your mirrors come from"], | |
| 107 | 108 | ["domains", "Failure domains", "failure domains and their last drill"], |
| 108 | 109 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 109 | 110 | ["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", | ||
| 160 | 161 | ]); |
| 161 | 162 | }); |
| 162 | 163 | |
| 164 | test("no agent is offered Connected origins", () => { | |
| 165 | expect(roster({ ...admin, kind: "agent" }).map(([id]) => id)).not.toContain("origins"); | |
| 166 | }); | |
| 167 | ||
| 163 | 168 | test("a member person is offered the signed-in roster without the admin entries", () => { |
| 164 | 169 | expect(roster(member)).toEqual([ |
| 165 | 170 | ["home", "Home", "your repositories"], |
| @@ -193,6 +198,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 193 | 198 | ["can", "What I can do", "your role and what it allows here"], |
| 194 | 199 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 195 | 200 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 201 | ["origins", "Connected origins", "the hosts your mirrors come from"], | |
| 196 | 202 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 197 | 203 | ["family", "Family", "the forge that sent this one, and the forges it sent"], |
| 198 | 204 | ["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 | }); |
new above
offline
The
spath is untested. Add a test for the success, error and not-found toasts.