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

Merged opened this

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

Merged

scout/forge:scout/1782-terminal-make-a-client-s-forge-spawn-fro → main 7 commits fork

  • Merged. scout/1782-terminal-make-a-client-s-forge-spawn-fro was merged into main as ac541b3.

Review

Sign in to review this change.
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
106106 only, shows a guest nothing, and names nothing of a placement in a repository
107107 you cannot read.
108108
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
109117 The family across forges: `:` → Family in the terminal, `/family` on the web,
110118 `GET /api/v1/family` and the MCP `family` tool all give the same view. The
111119 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[] = [
7070 "test/tui-onyou.test.ts",
7171 "test/tui-deploys.test.ts",
7272 "test/tui-access.test.ts",
73 "test/tui-newforge.test.ts",
7374 "test/tui-decider.test.ts",
7475 "test/claim-link.test.ts",
7576 "test/og-card.test.ts",
src/tui/forge.ts +9
@@ -44,6 +44,7 @@ import { stalledScreen } from "./screens/stalled.ts";
4444 import { issuesScreen, newIssueScreen } from "./screens/issues.ts";
4545 import { limbsScreen } from "./screens/limbs.ts";
4646 import { newPullScreen } from "./screens/newpull.ts";
47 import { newForgeScreen } from "./screens/newforge.ts";
4748 import { newRepoScreen } from "./screens/newrepo.ts";
4849 import { repoScreen } from "./screens/repo.ts";
4950 import { signInScreen } from "./screens/signin.ts";
@@ -173,6 +174,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
173174 ctx.push(signInScreen(session, ctx, () => ctx.invalidate())),
174175 },
175176 );
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 });
176185 // What you asked for and what became of it, as /asked says it: a signed-in person's.
177186 if (session.signedIn)
178187 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs