Public: anyone can read this; the crew can change it.
Terminal: make a client's forge (spawn) from src/tui, with the same fields as /forges/new #1951
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1782-terminal-make-a-client-s-forge-spawn-fro → main 7 commits fork
5 files
changed +390
−0
README.md +8
| @@ -106,6 +106,14 @@ thoughts, then the issues you can write to that are not shipped, oldest first | ||
| 106 | 106 | only, shows a guest nothing, and names nothing of a placement in a repository |
| 107 | 107 | you cannot read. |
| 108 | 108 | |
| 109 | A client's forge: `Ctrl+K`, then **New forge** (any signed-in person's, as the | |
| 110 | page is), is `/forges/new` in the terminal — name, for whom, what it is for, | |
| 111 | apps to fork (`owner/name`, comma-separated), a fresh one-line app, model key | |
| 112 | (hidden as typed), API base URL and budget, through the same `spawnDoor` as the | |
| 113 | page, `POST /api/v1/forges` and the MCP `spawn`. `Ctrl+P` shows the plan and | |
| 114 | makes nothing; `enter` on the plan spawns, and the claim link is shown once, in | |
| 115 | a dialog, never kept. | |
| 116 | ||
| 109 | 117 | The family across forges: `:` → Family in the terminal, `/family` on the web, |
| 110 | 118 | `GET /api/v1/family` and the MCP `family` tool all give the same view. The |
| 111 | 119 | forge that sent this one is shown to anyone; the forges it sent, only to a |
scripts/gate.ts +1
| @@ -70,6 +70,7 @@ export const GATE: readonly string[] = [ | ||
| 70 | 70 | "test/tui-onyou.test.ts", |
| 71 | 71 | "test/tui-deploys.test.ts", |
| 72 | 72 | "test/tui-access.test.ts", |
| 73 | "test/tui-newforge.test.ts", | |
| 73 | 74 | "test/tui-decider.test.ts", |
| 74 | 75 | "test/claim-link.test.ts", |
| 75 | 76 | "test/og-card.test.ts", |
src/tui/forge.ts +9
| @@ -44,6 +44,7 @@ import { stalledScreen } from "./screens/stalled.ts"; | ||
| 44 | 44 | import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; |
| 45 | 45 | import { limbsScreen } from "./screens/limbs.ts"; |
| 46 | 46 | import { newPullScreen } from "./screens/newpull.ts"; |
| 47 | import { newForgeScreen } from "./screens/newforge.ts"; | |
| 47 | 48 | import { newRepoScreen } from "./screens/newrepo.ts"; |
| 48 | 49 | import { repoScreen } from "./screens/repo.ts"; |
| 49 | 50 | import { signInScreen } from "./screens/signin.ts"; |
| @@ -173,6 +174,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 173 | 174 | ctx.push(signInScreen(session, ctx, () => ctx.invalidate())), |
| 174 | 175 | }, |
| 175 | 176 | ); |
| 177 | // A client's forge, as /forges/new makes it: any signed-in person's; the door decides the rest. | |
| 178 | if (session.signedIn) | |
| 179 | commands.push({ | |
| 180 | id: "new-forge", | |
| 181 | title: "New forge", | |
| 182 | hint: "spawn a client's forge and get its claim link", | |
| 183 | run: () => ctx.push(newForgeScreen(session, ctx)), | |
| 184 | }); | |
| 176 | 185 | // What you asked for and what became of it, as /asked says it: a signed-in person's. |
| 177 | 186 | if (session.signedIn) |
| 178 | 187 | commands.push({ |
src/tui/screens/newforge.ts added +211
| @@ -0,0 +1,211 @@ | ||
| 1 | /** | |
| 2 | * `/forges/new` in the terminal (#1782): a client's forge, with the page's | |
| 3 | * fields. It goes through `spawnDoor` as the page and `POST /api/v1/forges` | |
| 4 | * do, so every rule (names, budget, the key, the apps you may read) is the | |
| 5 | * door's and not this screen's. **Ctrl+P** shows the plan and makes nothing; | |
| 6 | * **Enter** on the plan spawns; the claim link is then shown once, in a | |
| 7 | * dialog, and kept nowhere. Any signed-in person's, as the page is. | |
| 8 | */ | |
| 9 | ||
| 10 | import { childUrlOf, spawnDoor } from "../../spawn/door.ts"; | |
| 11 | import { MAX_BUDGET_USD, MIN_BUDGET_USD } from "../../spawn/plan.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import { style } from "../term/style.ts"; | |
| 15 | import { truncate } from "../term/width.ts"; | |
| 16 | import { field } from "../ui/widgets.ts"; | |
| 17 | import type { Session } from "../session.ts"; | |
| 18 | import { linkScreen, runAsync } from "./common.ts"; | |
| 19 | ||
| 20 | interface Spec { | |
| 21 | readonly key: string; | |
| 22 | readonly label: string; | |
| 23 | readonly hint: string; | |
| 24 | readonly secret?: boolean; | |
| 25 | } | |
| 26 | ||
| 27 | const SPECS: readonly Spec[] = [ | |
| 28 | { key: "name", label: "Name", hint: "Lowercase letters, digits and dashes." }, | |
| 29 | { key: "for", label: "For whom", hint: "A person or client, for your list of forges sent. Optional." }, | |
| 30 | { key: "direction", label: "What it is for", hint: "The crew's first direction. Optional." }, | |
| 31 | { key: "apps", label: "Apps to fork", hint: "owner/name, comma-separated. Optional." }, | |
| 32 | { key: "fresh", label: "Or a fresh app", hint: "One line on what it is. Optional." }, | |
| 33 | { key: "modelKey", label: "Model key", hint: "Sealed in the child, never shown again. Optional.", secret: true }, | |
| 34 | { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for Anthropic. Optional." }, | |
| 35 | { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` }, | |
| 36 | ]; | |
| 37 | ||
| 38 | /** The door's "field: reason" refusal, as the field it names and the reason. */ | |
| 39 | function refusal(body: Record<string, unknown>): { key?: string; text: string } { | |
| 40 | const text = String(body["message"] ?? "refused"); | |
| 41 | const m = /^([A-Za-z]+): (.*)$/.exec(text); | |
| 42 | return m && body["error"] === "invalid" ? { key: m[1]!, text: m[2]! } : { text }; | |
| 43 | } | |
| 44 | ||
| 45 | export function newForgeScreen(f: Session, ctx: Ctx): Screen { | |
| 46 | const values: Record<string, string> = {}; | |
| 47 | let focus = 0; | |
| 48 | let errorKey: string | undefined; | |
| 49 | let error: string | undefined; | |
| 50 | let plan: string[] | undefined; | |
| 51 | let busy = false; | |
| 52 | ||
| 53 | const val = (k: string): string => (values[k] ?? "").trim(); | |
| 54 | ||
| 55 | const request = (dryRun: boolean): Record<string, unknown> => { | |
| 56 | const apps = val("apps").split(",").map((a) => a.trim()).filter((a) => a !== ""); | |
| 57 | return { | |
| 58 | name: val("name"), | |
| 59 | apps, | |
| 60 | ...(val("fresh") === "" ? {} : { fresh: val("fresh") }), | |
| 61 | ...(val("for") === "" ? {} : { for: val("for") }), | |
| 62 | ...(val("direction") === "" ? {} : { direction: val("direction") }), | |
| 63 | ...(val("modelKey") === "" ? {} : { modelKey: val("modelKey") }), | |
| 64 | ...(val("modelEndpoint") === "" ? {} : { modelEndpoint: val("modelEndpoint") }), | |
| 65 | ...(val("budgetUsd") === "" ? {} : { budgetUsd: Number(val("budgetUsd")) }), | |
| 66 | dryRun, | |
| 67 | }; | |
| 68 | }; | |
| 69 | ||
| 70 | const door = async (dryRun: boolean) => { | |
| 71 | const url = new URL(f.app.config.url ?? `http://${f.app.config.host ?? "localhost"}`); | |
| 72 | const hctx = { | |
| 73 | app: f.app, | |
| 74 | request: new Request(url.href, { method: "POST" }), | |
| 75 | url, | |
| 76 | params: {}, | |
| 77 | auth: { user: f.user, scope: undefined, via: "none" as const }, | |
| 78 | theme: undefined, | |
| 79 | }; | |
| 80 | return spawnDoor(hctx, f.user, request(dryRun)); | |
| 81 | }; | |
| 82 | ||
| 83 | const refuse = (body: Record<string, unknown>): void => { | |
| 84 | const r = refusal(body); | |
| 85 | values["modelKey"] = ""; | |
| 86 | error = r.text; | |
| 87 | errorKey = r.key; | |
| 88 | const at = SPECS.findIndex((s) => s.key === r.key); | |
| 89 | if (at >= 0) focus = at; | |
| 90 | plan = undefined; | |
| 91 | ctx.toast(r.text, "danger"); | |
| 92 | }; | |
| 93 | ||
| 94 | const run = (dryRun: boolean): void => { | |
| 95 | if (busy) return; | |
| 96 | busy = true; | |
| 97 | error = undefined; | |
| 98 | errorKey = undefined; | |
| 99 | runAsync(ctx, async () => { | |
| 100 | try { | |
| 101 | const made = await door(dryRun); | |
| 102 | if (made.status >= 400) return refuse(made.body); | |
| 103 | const body = made.body; | |
| 104 | if (dryRun) { | |
| 105 | const copied = (body["copied"] as string[] | undefined) ?? []; | |
| 106 | const bytes = Number(body["bytes"] ?? 0); | |
| 107 | const budget = (body["plan"] as { budgetUsd?: number | null } | undefined)?.budgetUsd ?? null; | |
| 108 | plan = [ | |
| 109 | `A forge at ${childUrlOf(f.app, val("name"))}.`, | |
| 110 | "Whoever opens the link becomes its admin.", | |
| 111 | ...(budget === null ? [] : [`Its crew runs on your key, up to $${budget} for the key's whole life.`]), | |
| 112 | "Copied into it:", | |
| 113 | ...copied.map((c) => ` ${c}`), | |
| 114 | `About ${Math.max(1, Math.round(bytes / 1024))} KB. Nothing is made until you press enter.`, | |
| 115 | ...(typeof body["keyNote"] === "string" ? [body["keyNote"]] : []), | |
| 116 | ]; | |
| 117 | return; | |
| 118 | } | |
| 119 | values["modelKey"] = ""; | |
| 120 | const link = body["claimUrl"]; | |
| 121 | const name = String(body["name"] ?? val("name")); | |
| 122 | if (typeof link === "string") { | |
| 123 | ctx.toast(`${name} is ready`, "success"); | |
| 124 | ctx.replace(linkScreen(ctx, link)); | |
| 125 | } else { | |
| 126 | ctx.toast(`${name} already exists; its link is not shown again`, "attn"); | |
| 127 | ctx.pop(); | |
| 128 | } | |
| 129 | } finally { | |
| 130 | busy = false; | |
| 131 | ctx.invalidate(); | |
| 132 | } | |
| 133 | }); | |
| 134 | }; | |
| 135 | ||
| 136 | return { | |
| 137 | title: "New forge", | |
| 138 | keys: [ | |
| 139 | binding(["tab"], "next field"), | |
| 140 | binding(["ctrl+p"], "see the plan"), | |
| 141 | binding(["enter"], "spawn (on the plan)"), | |
| 142 | binding(["esc"], "back"), | |
| 143 | ], | |
| 144 | render(area, s) { | |
| 145 | const c = ctx.theme.c; | |
| 146 | if (plan !== undefined) { | |
| 147 | s.text(area.x, area.y, truncate("The plan", area.w), style(c.accent, c.bg), area.x + area.w); | |
| 148 | plan.forEach((line, i) => { | |
| 149 | if (i + 2 < area.h) | |
| 150 | s.text(area.x, area.y + 2 + i, truncate(line, area.w), style(c.fg, c.bg), area.x + area.w); | |
| 151 | }); | |
| 152 | ctx.setCursor(null); | |
| 153 | return; | |
| 154 | } | |
| 155 | // Three rows a field; the window scrolls so the focused one is in view. | |
| 156 | const rows = Math.max(1, Math.floor(area.h / 3)); | |
| 157 | const first = Math.max(0, Math.min(focus - rows + 1, SPECS.length - rows)); | |
| 158 | let cursor: { x: number; y: number } | null = null; | |
| 159 | SPECS.slice(first, first + rows).forEach((spec, n) => { | |
| 160 | const i = first + n; | |
| 161 | const raw = values[spec.key] ?? ""; | |
| 162 | const shown = spec.secret === true ? "•".repeat([...raw].length) : raw; | |
| 163 | const at = field(s, { x: area.x, y: area.y + n * 3, w: Math.min(70, area.w), h: 3 }, c, { | |
| 164 | label: spec.label, | |
| 165 | value: shown, | |
| 166 | focused: focus === i, | |
| 167 | ...(focus === i ? { cursor: [...shown].length } : {}), | |
| 168 | ...(error !== undefined && (errorKey === spec.key || (errorKey === undefined && focus === i)) | |
| 169 | ? { error } | |
| 170 | : { hint: spec.hint }), | |
| 171 | }); | |
| 172 | if (focus === i) cursor = at; | |
| 173 | }); | |
| 174 | ctx.setCursor(cursor); | |
| 175 | }, | |
| 176 | paste(text) { | |
| 177 | if (plan !== undefined) return true; | |
| 178 | const k = SPECS[focus]!.key; | |
| 179 | values[k] = (values[k] ?? "") + text.replace(/\s+/g, " ").trim(); | |
| 180 | return true; | |
| 181 | }, | |
| 182 | key(e) { | |
| 183 | if (plan !== undefined) { | |
| 184 | if (e.key === "enter") run(false); | |
| 185 | else if (e.key === "esc") plan = undefined; | |
| 186 | return true; | |
| 187 | } | |
| 188 | if (e.ctrl && e.key === "p") { | |
| 189 | run(true); | |
| 190 | return true; | |
| 191 | } | |
| 192 | if (e.key === "tab" || e.key === "enter") { | |
| 193 | focus = (focus + 1) % SPECS.length; | |
| 194 | return true; | |
| 195 | } | |
| 196 | if (e.key === "backspace") { | |
| 197 | const k = SPECS[focus]!.key; | |
| 198 | values[k] = [...(values[k] ?? "")].slice(0, -1).join(""); | |
| 199 | error = undefined; | |
| 200 | return true; | |
| 201 | } | |
| 202 | if (!e.ctrl && !e.alt && [...e.key].length === 1) { | |
| 203 | const k = SPECS[focus]!.key; | |
| 204 | values[k] = (values[k] ?? "") + e.key; | |
| 205 | error = undefined; | |
| 206 | return true; | |
| 207 | } | |
| 208 | return false; | |
| 209 | }, | |
| 210 | }; | |
| 211 | } |
test/tui-newforge.test.ts added +161
| @@ -0,0 +1,161 @@ | ||
| 1 | /** | |
| 2 | * "New forge" in the terminal (#1782): the page's fields through `spawnDoor`, | |
| 3 | * the plan before the spawn, the claim link once, any signed-in person's. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, readdir, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createUser, type User } from "../src/core/users.ts"; | |
| 12 | import { usePlacement } from "../src/spawn/door.ts"; | |
| 13 | import { hostedPlacement } from "../src/spawn/placement.ts"; | |
| 14 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 15 | import { newForgeScreen } from "../src/tui/screens/newforge.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dir: string; | |
| 23 | let app: App; | |
| 24 | let admin: User; | |
| 25 | let bob: User; | |
| 26 | ||
| 27 | const identity = (user?: User) => ({ | |
| 28 | sshUsername: user?.username ?? "git", | |
| 29 | remoteAddress: "127.0.0.1", | |
| 30 | fingerprint: "SHA256:test", | |
| 31 | user, | |
| 32 | }); | |
| 33 | const NAME_REASON = "name must be 1-39 characters of a-z"; | |
| 34 | const BUDGET_REASON = "a budget is for a model key"; | |
| 35 | const children = async () => (await readdir(join(dir, "spawn")).catch(() => [] as string[])).length; | |
| 36 | ||
| 37 | function open(user: User) { | |
| 38 | const terminal = headlessTerminal(120, 40); | |
| 39 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false }); | |
| 40 | tui.start(); | |
| 41 | tui.ctx.push(newForgeScreen(session, tui.ctx)); | |
| 42 | return { tui, terminal }; | |
| 43 | } | |
| 44 | ||
| 45 | beforeAll(async () => { | |
| 46 | dir = await mkdtemp(join(tmpdir(), "forge-tui-newforge-")); | |
| 47 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 48 | const mk = async (username: string, isAdmin: boolean) => { | |
| 49 | const r = await createUser(app.db, { | |
| 50 | trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", isAdmin, | |
| 51 | }); | |
| 52 | if (!r.ok) throw new Error(r.error); | |
| 53 | return r.value; | |
| 54 | }; | |
| 55 | admin = await mk("alice", true); | |
| 56 | bob = await mk("bob", false); | |
| 57 | const repo = await createRepo(app, admin, { name: "bakery", visibility: "public" }); | |
| 58 | if (!repo.ok) throw new Error("repo"); | |
| 59 | usePlacement(app, hostedPlacement({ host: "forge.test" })); | |
| 60 | }); | |
| 61 | afterAll(async () => { | |
| 62 | app.db.close(); | |
| 63 | await rm(dir, { recursive: true, force: true }); | |
| 64 | }); | |
| 65 | ||
| 66 | /** Open the screen, run `body`, and always stop the terminal. */ | |
| 67 | async function withScreen(user: User, body: (h: ReturnType<typeof open>) => Promise<void>) { | |
| 68 | const h = open(user); | |
| 69 | try { | |
| 70 | await settleApp(h.tui); | |
| 71 | await body(h); | |
| 72 | } finally { | |
| 73 | h.tui.stop?.(); | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | describe("the new forge screen", () => { | |
| 78 | test("the palette offers it to any signed-in person, as the page is", () => { | |
| 79 | const ids = (u?: User) => forgeCommands(new Session(app, identity(u)))({} as never).map((c) => c.id); | |
| 80 | expect(ids(admin)).toContain("new-forge"); | |
| 81 | expect(ids(bob)).toContain("new-forge"); | |
| 82 | expect(ids()).not.toContain("new-forge"); | |
| 83 | }); | |
| 84 | ||
| 85 | test("the plan is shown first and makes nothing; enter spawns and shows the claim link once", () => | |
| 86 | withScreen(admin, async ({ tui, terminal }) => { | |
| 87 | const before = await children(); | |
| 88 | terminal.feed("kai\t\t\talice/bakery\r"); | |
| 89 | terminal.feed("\x10"); // ctrl+p | |
| 90 | await settleApp(tui); | |
| 91 | const plan = tui.screen.toText(); | |
| 92 | expect(plan).toContain("The plan"); | |
| 93 | expect(plan).toContain("kai--forge.test"); | |
| 94 | expect(await children()).toBe(before); | |
| 95 | terminal.feed("\r"); | |
| 96 | await settleApp(tui); | |
| 97 | expect(await children()).toBe(before + 1); | |
| 98 | expect(tui.screen.toText()).toMatch(/https:\/\/kai--forge\.test\/claim#/); | |
| 99 | })); | |
| 100 | ||
| 101 | test("the door's refusal lands on the field it names, with focus there, and nothing is made", () => | |
| 102 | withScreen(admin, async ({ tui, terminal }) => { | |
| 103 | const before = await children(); | |
| 104 | terminal.feed("Bad Name!\t\t\t"); // focus on "Apps to fork", name is wrong | |
| 105 | terminal.feed("\x10"); | |
| 106 | await settleApp(tui); | |
| 107 | const text = tui.screen.toText(); | |
| 108 | expect(text).not.toContain("The plan"); | |
| 109 | expect(text).toContain(NAME_REASON); | |
| 110 | terminal.feed("Z"); // focus went back to Name | |
| 111 | await settleApp(tui); | |
| 112 | expect(tui.screen.toText()).toContain("Bad Name!Z"); | |
| 113 | expect(await children()).toBe(before); | |
| 114 | })); | |
| 115 | ||
| 116 | test("a key without a budget is refused by the door, naming the budget", () => | |
| 117 | withScreen(admin, async ({ tui, terminal }) => { | |
| 118 | const before = await children(); | |
| 119 | terminal.feed("lee\t\t\t\t\tsk-ant-api03-child-key-AAAAAAAAAAAAAAAAAAAAAAAA\x10"); | |
| 120 | await settleApp(tui); | |
| 121 | const text = tui.screen.toText(); | |
| 122 | expect(text).not.toContain("The plan"); | |
| 123 | expect(text).toContain("budget"); | |
| 124 | expect(text).not.toContain("sk-ant-api03"); | |
| 125 | expect(await children()).toBe(before); | |
| 126 | })); | |
| 127 | ||
| 128 | test("a budget without a key is refused, with the door's reason", () => | |
| 129 | withScreen(admin, async ({ tui, terminal }) => { | |
| 130 | const before = await children(); | |
| 131 | terminal.feed("max\t\t\t\t\t\t\t5\x10"); | |
| 132 | await settleApp(tui); | |
| 133 | const text = tui.screen.toText(); | |
| 134 | expect(text).not.toContain("The plan"); | |
| 135 | expect(text).toContain(BUDGET_REASON); | |
| 136 | expect(await children()).toBe(before); | |
| 137 | })); | |
| 138 | ||
| 139 | test("a refused key is cleared from the form", () => | |
| 140 | withScreen(admin, async ({ tui, terminal }) => { | |
| 141 | terminal.feed("Bad Name!\t\t\t\t\tsk-ant-api03-child-key-AAAAAAAAAAAAAAAAAAAAAAAA\x10"); | |
| 142 | await settleApp(tui); | |
| 143 | terminal.feed("\t\t\t\t\t"); // back to the key field, past the name | |
| 144 | await settleApp(tui); | |
| 145 | expect(tui.screen.toText()).not.toContain("••••"); | |
| 146 | })); | |
| 147 | ||
| 148 | test("a person who is not an admin gets a plan and spawns, as on the page", () => | |
| 149 | withScreen(bob, async ({ tui, terminal }) => { | |
| 150 | const before = await children(); | |
| 151 | terminal.feed("zed"); | |
| 152 | terminal.feed("\x10"); | |
| 153 | await settleApp(tui); | |
| 154 | expect(tui.screen.toText()).toContain("The plan"); | |
| 155 | expect(await children()).toBe(before); | |
| 156 | terminal.feed("\r"); | |
| 157 | await settleApp(tui); | |
| 158 | expect(await children()).toBe(before + 1); | |
| 159 | expect(tui.screen.toText()).toMatch(/https:\/\/zed--forge\.test\/claim#/); | |
| 160 | })); | |
| 161 | }); |
new above
offline