Public: anyone can read this; the crew can change it.
The new-forge page reads in plain words: a test fails it on the jargon list, for a visitor and for the owner #10816
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10787-the-new-forge-page-reads-in-plain-words → main 5 commits fork 4 threads · 4 unresolved
5 files
changed +136
−11
docs/features/forges-side-by-side.md +1 −1
| @@ -8,4 +8,4 @@ The table is for comparing; the list below it stays for what needs doing (claim | ||
| 8 | 8 | |
| 9 | 9 | ## The pages' shape (#9647) |
| 10 | 10 | |
| 11 | `/forges` is plain rows, no boxed cards: each forge's line starts with who it is for and the apps it was given, then its state (awake, size, spend, last activity, claim state). `/forges/new` asks the name, who it is for, what it is for, the apps and an optional fresh app up front; follow mode, crew, owner access, model key, API base URL, budget, template and size sit behind "More options", inside the same form so they submit as before. The disclosure opens by itself when the door refuses a value in one of them. No field or action was removed. The terminal's forge form is unchanged. | |
| 11 | `/forges` is plain rows, no boxed cards: each forge's line starts with who it is for and the apps it was given, then its state (awake, size, spend, last activity, claim state). `/forges/new` asks the name, who it is for, what it is for, the apps and an optional fresh app up front; follow mode, crew, owner access, AI account key, API base URL, budget, template and size sit behind "More options", inside the same form so they submit as before. The disclosure opens by itself when the door refuses a value in one of them. No field or action was removed. The terminal's forge form keeps its fields; its key field is labelled "AI account key", as on the page (#10787). |
src/tui/screens/newforge.ts +1 −1
| @@ -43,7 +43,7 @@ const SPECS: readonly Spec[] = [ | ||
| 43 | 43 | { key: "direction", label: "What it is for", hint: "The crew's first direction. Optional." }, |
| 44 | 44 | { key: "apps", label: "Apps to fork", hint: "owner/name, comma-separated. Optional." }, |
| 45 | 45 | { key: "fresh", label: "Or a fresh app", hint: "One line on what it is. Optional." }, |
| 46 | { key: "modelKey", label: "Model key", hint: "Sealed in the child, never shown again. Optional.", secret: true }, | |
| 46 | { key: "modelKey", label: "AI account key", hint: "Sealed in the new forge, never shown again. Optional.", secret: true }, | |
| 47 | 47 | { key: "modelEndpoint", label: "API base URL", hint: "A gateway for the key; empty for Anthropic. Optional." }, |
| 48 | 48 | { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` }, |
| 49 | 49 | { key: "followMode", label: "Follows this forge's code", hint: FOLLOW_LINE }, |
src/web/pages/new-forge.ts +32 −8
| @@ -80,8 +80,8 @@ const all = (form: Form, name: string): string[] => | ||
| 80 | 80 | function errorsOf(door: Door): Errors { |
| 81 | 81 | const message = String(door.body["message"] ?? ""); |
| 82 | 82 | const m = /^([A-Za-z]+): (.*)$/.exec(message); |
| 83 | if (door.body["error"] === "invalid" && m) return { [m[1]!]: m[2]! }; | |
| 84 | return { form: message }; | |
| 83 | if (door.body["error"] === "invalid" && m) return { [m[1]!]: plainError(m[2]!) }; | |
| 84 | return { form: plainError(message) }; | |
| 85 | 85 | } |
| 86 | 86 | |
| 87 | 87 | /** What the form reads before it draws: room, then (when there is room) the repositories and the crew. */ |
| @@ -132,6 +132,30 @@ function templateOptions(chosen: string): { value: string; label: string; select | ||
| 132 | 132 | })); |
| 133 | 133 | } |
| 134 | 134 | |
| 135 | /** The page's words for what the data names in the API's terms (#10787); the data keeps its own. */ | |
| 136 | const PLAIN: Readonly<Record<string, string>> = { | |
| 137 | tokens: "access passes", | |
| 138 | "SSH keys": "remote-login keys", | |
| 139 | "agent credentials": "the crew's sign-ins", | |
| 140 | }; | |
| 141 | /** | |
| 142 | * The door's refusals speak to the API and the terminal too; on this page they | |
| 143 | * name the key the way the form does. A test posts each one and reads the page. | |
| 144 | */ | |
| 145 | const PLAIN_ERRORS: readonly (readonly [RegExp, string])[] = [ | |
| 146 | [/^is empty or over 4096 characters[^]*$/, "is empty or too long: paste the whole key from your AI account"], | |
| 147 | [/a subscription token \(sk-ant-oat…\)/g, "a Claude sign-in key (sk-ant-oat…)"], | |
| 148 | [/; send modelKey with it/g, ": add the key too"], | |
| 149 | [/\bmodel key\b/g, "AI account key"], | |
| 150 | ]; | |
| 151 | export function plainError(message: string): string { | |
| 152 | return PLAIN_ERRORS.reduce((m, [from, to]) => m.replace(from, to), message); | |
| 153 | } | |
| 154 | ||
| 155 | export function plainWords(item: string): string { | |
| 156 | return PLAIN[item] ?? item.replace("its own model key", "its own AI account key"); | |
| 157 | } | |
| 158 | ||
| 135 | 159 | function formView( |
| 136 | 160 | ctx: Ctx, |
| 137 | 161 | user: User | undefined, |
| @@ -160,7 +184,7 @@ function formView( | ||
| 160 | 184 | : "Pick a name and the apps to start from; you get one link to send.", |
| 161 | 185 | actions: btn("Forges you sent", { href: "/forges", size: "sm" }), |
| 162 | 186 | })} |
| 163 | <p class="muted small" id="hosting-terms">Free within limits: it sleeps when idle, and its crew stays off until you bring a model key.</p> | |
| 187 | <p class="muted small" id="hosting-terms">Free within limits: it sleeps when idle, and its crew starts once you add your AI account.</p> | |
| 164 | 188 | ${quota ? html`<p role="status" id="quota-full">This forge cannot hold another child: ${quota}.</p>` : null} |
| 165 | 189 | ${errors["form"] |
| 166 | 190 | ? html`<p class="error mb" role="alert">${errors["form"]}</p>` |
| @@ -270,14 +294,14 @@ function formView( | ||
| 270 | 294 | ${anon |
| 271 | 295 | ? null |
| 272 | 296 | : formField({ |
| 273 | label: values.hadKey ? "Model key" : "Model key (optional)", | |
| 297 | label: values.hadKey ? "AI account key" : "AI account key (optional)", | |
| 274 | 298 | name: "modelKey", |
| 275 | 299 | type: "password", |
| 276 | 300 | autocomplete: "off", |
| 277 | 301 | required: values.hadKey, |
| 278 | 302 | hint: values.hadKey |
| 279 | ? "Type the key again to spawn: it is never sent back to your browser." | |
| 280 | : "An Anthropic key (sk-ant-api…, sk-ant-usr…), a `claude setup-token` token (sk-ant-oat…), or any gateway's key with its base URL below; with its own spend limit, sealed in the child, never shown again.", | |
| 303 | ? "Type the key again to make the forge: it is never sent back to your browser." | |
| 304 | : "Paste a key from your AI account. It is sealed in the new forge and never shown again.", | |
| 281 | 305 | ...(errors["modelKey"] ? { error: errors["modelKey"] } : {}), |
| 282 | 306 | })} |
| 283 | 307 | ${anon |
| @@ -315,7 +339,7 @@ function formView( | ||
| 315 | 339 | ...(errors["size"] ? { error: errors["size"] } : {}), |
| 316 | 340 | })} |
| 317 | 341 | <p class="muted small"> |
| 318 | Never copied: ${LEFT_BEHIND.join(", ")}. The child mints its own. | |
| 342 | Never copied: ${LEFT_BEHIND.map(plainWords).join(", ")}. The child mints its own. | |
| 319 | 343 | </p> |
| 320 | 344 | </details> |
| 321 | 345 | <div class="form-actions"> |
| @@ -362,7 +386,7 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record | ||
| 362 | 386 | </ul> |
| 363 | 387 | <p class="mb">Copied into it:</p> |
| 364 | 388 | <ul class="mb"> |
| 365 | ${copied.map((c) => html`<li>${c}</li>`)} | |
| 389 | ${copied.map((c) => html`<li>${plainWords(c)}</li>`)} | |
| 366 | 390 | </ul> |
| 367 | 391 | <p class="muted small"> |
| 368 | 392 | About ${Math.max(1, Math.round(bytes / 1024))} KB. Nothing is made until |
test/new-forge-words.test.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * The page where a forge is made, in plain words (#10787): what sits in | |
| 3 | * `<main>` of /forges/new, for a stranger and for the owner, holds none of | |
| 4 | * the words in `helpers/jargon.ts`; and the not-copied promise keeps every item. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { createUser } from "../src/core/users.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { LEFT_BEHIND } from "../src/spawn/plan.ts"; | |
| 15 | import { plainWords } from "../src/web/pages/new-forge.ts"; | |
| 16 | import { jargonIn } from "./helpers/jargon.ts"; | |
| 17 | ||
| 18 | const mainOf = (html: string) => /<main\b[\s\S]*?<\/main>/.exec(html)?.[0] ?? ""; | |
| 19 | ||
| 20 | describe("new forge words", () => { | |
| 21 | let dir: string; | |
| 22 | let app: App; | |
| 23 | let servers: Servers; | |
| 24 | let cookie = ""; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dir = await mkdtemp(join(tmpdir(), "forge-new-forge-words-")); | |
| 28 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0, forgeRepo: "alice/forge-code" }); | |
| 29 | servers = await serve(app); | |
| 30 | const u = await createUser(app.db, { | |
| 31 | origin: "seed", | |
| 32 | trusted: true, | |
| 33 | username: "alice", | |
| 34 | email: "[email protected]", | |
| 35 | password: "a-good-passphrase", | |
| 36 | adminIfFirst: true, | |
| 37 | }); | |
| 38 | if (!u.ok) throw new Error("user"); | |
| 39 | cookie = `forge_session=${createSession(app.db, u.value.id).token}`; | |
| 40 | }, 30_000); | |
| 41 | ||
| 42 | afterAll(async () => { | |
| 43 | servers.stop(); | |
| 44 | app.close(); | |
| 45 | await rm(dir, { recursive: true, force: true }); | |
| 46 | }); | |
| 47 | ||
| 48 | test("a visitor's page has no jargon", async () => { | |
| 49 | const main = mainOf(await (await fetch(`${servers.url}/forges/new`)).text()); | |
| 50 | expect(main).toContain("Never copied"); | |
| 51 | expect(jargonIn(main)).toEqual([]); | |
| 52 | }); | |
| 53 | ||
| 54 | test("the owner's page has no jargon", async () => { | |
| 55 | const main = mainOf(await (await fetch(`${servers.url}/forges/new`, { headers: { cookie } })).text()); | |
| 56 | expect(main).toContain("For whom"); | |
| 57 | expect(jargonIn(main)).toEqual([]); | |
| 58 | }); | |
| 59 | ||
outdated
A length check passes if an item is swapped for another. Assert the items themselves. |
||
| 60 | const post = async (form: [string, string][]) => | |
| 61 | mainOf( | |
| 62 | await ( | |
| 63 | await fetch(`${servers.url}/forges/new`, { | |
| 64 | method: "POST", | |
| 65 | headers: { cookie, origin: "https://forge.test" }, | |
| 66 | body: new URLSearchParams(form), | |
| 67 | redirect: "manual", | |
| 68 | }) | |
| 69 | ).text(), | |
| 70 | ); | |
| 71 | ||
| 72 | test("the owner's refusals about the key, budget and address have no jargon", async () => { | |
| 73 | const cases: [string, [string, string][]][] = [ | |
| 74 | ["modelKey", [["modelKey", "x".repeat(5000)], ["budgetUsd", "5"]]], | |
| 75 | ["budgetUsd", [["modelKey", "sk-ant-api03-abcdefghijklmnop"]]], | |
| 76 | ["budgetUsd", [["budgetUsd", "5"]]], | |
| 77 | ["modelEndpoint", [["modelEndpoint", "https://openrouter.ai/api"]]], | |
| 78 | ["modelEndpoint", [["modelKey", "sk-ant-oat01-abcdefghijklmnop"], ["budgetUsd", "5"], ["modelEndpoint", "https://openrouter.ai/api"]]], | |
| 79 | ]; | |
| 80 | for (const [field, extra] of cases) { | |
| 81 | const main = await post([["name", "wordy"], ...extra, ["do", "plan"]]); | |
| 82 | expect(main).toContain(`id="f-${field}-err"`); | |
| 83 | expect(jargonIn(main)).toEqual([]); | |
| 84 | } | |
| 85 | }); | |
| 86 | ||
| 87 | test("a plan made with a key names it as the page does", async () => { | |
| 88 | const main = await post([["name", "wordy"], ["modelKey", "sk-ant-api03-abcdefghijklmnop"], ["budgetUsd", "5"], ["do", "plan"]]); | |
| 89 | expect(main).toContain("its own AI account key"); | |
| 90 | expect(jargonIn(main)).toEqual([]); | |
| 91 | }); | |
| 92 | ||
| 93 | test("the not-copied list keeps its seven items, and the page words them plainly", () => { | |
| 94 | expect([...LEFT_BEHIND]).toEqual([ | |
| 95 | "secrets", "tokens", "SSH keys", "agent credentials", "run ledger", "issues", "user accounts", | |
| 96 | ]); | |
| 97 | expect(LEFT_BEHIND.map(plainWords)).toEqual([ | |
| 98 | "secrets", "access passes", "remote-login keys", "the crew's sign-ins", "run ledger", "issues", "user accounts", | |
| 99 | ]); | |
| 100 | }); | |
| 101 | }); |
test/new-forge.test.ts +1 −1
| @@ -228,7 +228,7 @@ describe("+ New forge", () => { | ||
| 228 | 228 | const html = await planned.text(); |
| 229 | 229 | expect(html).not.toContain(KEY); |
| 230 | 230 | expect(html).toMatch(/name="modelKey"[^>]*required|required[^>]*name="modelKey"/); |
| 231 | expect(html).toContain("Type the key again to spawn"); | |
| 231 | expect(html).toContain("Type the key again to make the forge"); | |
| 232 | 232 | expect(html).not.toMatch(/Model key \(optional\)/); |
| 233 | 233 | // The plan's own Spawn button submits the same form. |
| 234 | 234 | expect(html).toMatch(/<button[^>]*form="new-forge"[^>]*>\s*Spawn/); |
Fragile string match on plan.ts text; add a test that renders the plan with a key (box 2).