Public: anyone can read this; the crew can change it.
/forges/new offers the starter templates: a picker with Blank first, and the plan says which one #7971
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7933-forges-new-offers-the-starter-templates → main 2 commits fork
6 files
changed +153
−6
README.md +2
| @@ -84,6 +84,8 @@ In the terminal, the Capacity screen (palette: Capacity, a forge admin who is a | ||
| 84 | 84 | |
| 85 | 85 | An admin's home and the terminal's Flow dialog also say when the forge is clogged and how long the last clog took to notice and to clear: `docs/features/clog-watch.md`. A fork whose parent is archived shows one quiet line instead of its ahead/behind count, and its admin can detach it from the parent (Settings › Danger zone, the terminal's About `x`, MCP `detach`): `docs/features/detach.md`. |
| 86 | 86 | |
| 87 | **Templates:** `/forges/new` has a Template picker, Blank first (the default) or Starter app; the plan names the one chosen. In the terminal's New forge screen, tab to Template and press left or right (or type `starter`); the plan shows `Template: …`. Agents: `template` on `spawn`. Blank sends nothing. | |
| 88 | ||
| 87 | 89 | **Forge it:** a **Forge it** button beside Fork on a repository page (and the terminal's palette entry `Forge it`) makes a fresh forge, named for the repository, with it as the app, and lands on that forge's claim link, where you pick a username and password and become its admin. A second press makes no second forge and shows no second link. Agents: `spawn` with `apps: ["owner/name"]`. `docs/features/forge-it.md`. |
| 88 | 90 | |
| 89 | 91 | A pull the merge queue holds as red alone (red on its own merge at its current head; cleared by a new head or a moved main) says so under its checks line: "red alone at <time>, waiting for a new head or a new main", on the pull page, its live checks line and the studio change step, and in the checks row of the pull's terminal screen (`redMark` in `src/checks/queue.ts`). Nothing is drawn when it is not held. |
src/spawn/templates.ts +8 −3
| @@ -10,7 +10,7 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 10 | 10 | export interface Template { |
| 11 | 11 | readonly id: string; |
| 12 | 12 | readonly title: string; |
| 13 | /** One sentence on what a forge made from it starts with. */ | |
| 13 | /** What a forge made from it starts with, as a phrase that reads after the title. */ | |
| 14 | 14 | readonly says: string; |
| 15 | 15 | readonly source: "builtin" | "repo"; |
| 16 | 16 | /** The repository a `repo` template is read from, as `owner/name`. */ |
| @@ -25,13 +25,13 @@ export const TEMPLATES: readonly Template[] = [ | ||
| 25 | 25 | { |
| 26 | 26 | id: DEFAULT_TEMPLATE, |
| 27 | 27 | title: "Blank", |
| 28 | says: "Starts with only the apps you fork and the fresh one you describe.", | |
| 28 | says: "Only the apps you fork and the fresh one you describe.", | |
| 29 | 29 | source: "builtin", |
| 30 | 30 | }, |
| 31 | 31 | { |
| 32 | 32 | id: "starter", |
| 33 | 33 | title: "Starter app", |
| 34 | says: "Starts with the forge's starter app: a server, a page, tests and its forge.json.", | |
| 34 | says: "A server, a page, tests and a forge.json, ready to change.", | |
| 35 | 35 | source: "builtin", |
| 36 | 36 | }, |
| 37 | 37 | ]; |
| @@ -43,6 +43,11 @@ export function parseTemplate(v: unknown): Result<string, string> { | ||
| 43 | 43 | return err(`template must be ${ids.slice(0, -1).join(", ")} or ${ids[ids.length - 1]}`); |
| 44 | 44 | } |
| 45 | 45 | |
| 46 | /** The title of a template already validated by `parseTemplate`. */ | |
| 47 | export function templateOf(id: string): Template { | |
| 48 | return TEMPLATES.find((t) => t.id === id) ?? TEMPLATES[0]!; | |
| 49 | } | |
| 50 | ||
| 46 | 51 | /** What each template is, for a page or an agent to offer. */ |
| 47 | 52 | export function templatesView(): { templates: readonly Template[]; default: string } { |
| 48 | 53 | return { templates: TEMPLATES, default: DEFAULT_TEMPLATE }; |
src/tui/screens/newforge.ts +10 −2
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { DEFAULT_SIZE, SIZES, type Size } from "../../spawn/sizes.ts"; |
| 11 | import { DEFAULT_TEMPLATE, TEMPLATES, templateOf } from "../../spawn/templates.ts"; | |
| 11 | 12 | import { childUrlOf, spawnDoor } from "../../spawn/door.ts"; |
| 12 | 13 | import { quotaFull } from "../../spawn/quota.ts"; |
| 13 | 14 | import { FOLLOW_LINE, FOLLOW_WORDS } from "../../spawn/follow_words.ts"; |
| @@ -32,6 +33,8 @@ interface Spec { | ||
| 32 | 33 | |
| 33 | 34 | const SIZE_CHOICES = Object.keys(SIZES) as Size[]; |
| 34 | 35 | |
| 36 | const TEMPLATE_CHOICES = TEMPLATES.map((t) => t.id); | |
| 37 | ||
| 35 | 38 | const SPECS: readonly Spec[] = [ |
| 36 | 39 | { key: "name", label: "Name", hint: "Lowercase letters, digits and dashes." }, |
| 37 | 40 | { key: "for", label: "For whom", hint: "A person or client, for your list of forges sent. Optional." }, |
| @@ -43,6 +46,7 @@ const SPECS: readonly Spec[] = [ | ||
| 43 | 46 | { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` }, |
| 44 | 47 | { key: "followMode", label: "Follows this forge's code", hint: FOLLOW_LINE }, |
| 45 | 48 | { key: "size", label: "Size", hint: "small (default), medium or large: left and right choose, or type it.", choices: SIZE_CHOICES }, |
| 49 | { key: "template", label: "Template", hint: "blank (default) or starter: left and right choose, or type it.", choices: TEMPLATE_CHOICES }, | |
| 46 | 50 | ]; |
| 47 | 51 | |
| 48 | 52 | /** The door's "field: reason" refusal, as the field it names and the reason. */ |
| @@ -78,6 +82,8 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 78 | 82 | ...(val("budgetUsd") === "" ? {} : { budgetUsd: Number(val("budgetUsd")) }), |
| 79 | 83 | // Small is the default: it is not recorded, as on the page. |
| 80 | 84 | ...(val("size") === "" || val("size") === DEFAULT_SIZE ? {} : { size: val("size") }), |
| 85 | // Blank is the default: it is not recorded, as on the page. | |
| 86 | ...(val("template") === "" || val("template") === DEFAULT_TEMPLATE ? {} : { template: val("template") }), | |
| 81 | 87 | dryRun, |
| 82 | 88 | }; |
| 83 | 89 | }; |
| @@ -121,6 +127,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 121 | 127 | const bytes = Number(body["bytes"] ?? 0); |
| 122 | 128 | const budget = (body["plan"] as { budgetUsd?: number | null } | undefined)?.budgetUsd ?? null; |
| 123 | 129 | const size = (body["plan"] as { size?: string } | undefined)?.size; |
| 130 | const template = (body["plan"] as { template?: string } | undefined)?.template; | |
| 124 | 131 | const mode = val("followMode") === "" ? "tell-me" : val("followMode"); |
| 125 | 132 | plan = [ |
| 126 | 133 | `A forge at ${childUrlOf(f.app, val("name"))}.`, |
| @@ -130,6 +137,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 130 | 137 | "Copied into it:", |
| 131 | 138 | ...copied.map((c) => ` ${c}`), |
| 132 | 139 | ...(size === undefined ? [] : [`Size: ${size}.`]), |
| 140 | ...(template === undefined ? [] : [`Template: ${templateOf(template).title}.`]), | |
| 133 | 141 | `About ${Math.max(1, Math.round(bytes / 1024))} KB. Nothing is made until you press enter.`, |
| 134 | 142 | ...(typeof body["keyNote"] === "string" ? [body["keyNote"]] : []), |
| 135 | 143 | ]; |
| @@ -182,7 +190,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 182 | 190 | let cursor: { x: number; y: number } | null = null; |
| 183 | 191 | SPECS.slice(first, first + rows).forEach((spec, n) => { |
| 184 | 192 | const i = first + n; |
| 185 | const raw = spec.choices === undefined ? (values[spec.key] ?? "") : (values[spec.key] || DEFAULT_SIZE); | |
| 193 | const raw = spec.choices === undefined ? (values[spec.key] ?? "") : (values[spec.key] || spec.choices[0]!); | |
| 186 | 194 | const shown = spec.secret === true ? "•".repeat([...raw].length) : raw; |
| 187 | 195 | const at = field(s, { x: area.x, y: area.y + n * 3, w: Math.min(70, area.w), h: 3 }, c, { |
| 188 | 196 | label: spec.label, |
| @@ -222,7 +230,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen { | ||
| 222 | 230 | if (choices !== undefined && step !== 0) { |
| 223 | 231 | // A choice can be cycled or typed; both end in a value the door checks. |
| 224 | 232 | const k = SPECS[focus]!.key; |
| 225 | const at = Math.max(0, choices.indexOf(values[k] || DEFAULT_SIZE)); | |
| 233 | const at = Math.max(0, choices.indexOf(values[k] || choices[0]!)); | |
| 226 | 234 | values[k] = choices[(at + step + choices.length) % choices.length]!; |
| 227 | 235 | error = undefined; |
| 228 | 236 | return true; |
src/web/pages/new-forge.ts +26 −1
| @@ -24,6 +24,7 @@ import { | ||
| 24 | 24 | MIN_BUDGET_USD, |
| 25 | 25 | } from "../../spawn/plan.ts"; |
| 26 | 26 | import { DEFAULT_SIZE, SIZES, sizesView } from "../../spawn/sizes.ts"; |
| 27 | import { DEFAULT_TEMPLATE, TEMPLATES, templateOf } from "../../spawn/templates.ts"; | |
| 27 | 28 | import { FOLLOW_LABELS, FOLLOW_WORDS } from "../../spawn/follow_words.ts"; |
| 28 | 29 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 29 | 30 | import { field, readForm, type Form } from "../form.ts"; |
| @@ -43,6 +44,7 @@ interface Values { | ||
| 43 | 44 | readonly budgetUsd: string; |
| 44 | 45 | readonly modelEndpoint: string; |
| 45 | 46 | readonly size: string; |
| 47 | readonly template: string; | |
| 46 | 48 | /** The last post carried a key: it is never sent back, so it is asked for again. */ |
| 47 | 49 | readonly hadKey: boolean; |
| 48 | 50 | } |
| @@ -59,6 +61,7 @@ const EMPTY: Values = { | ||
| 59 | 61 | budgetUsd: "", |
| 60 | 62 | modelEndpoint: "", |
| 61 | 63 | size: DEFAULT_SIZE, |
| 64 | template: DEFAULT_TEMPLATE, | |
| 62 | 65 | hadKey: false, |
| 63 | 66 | }; |
| 64 | 67 | |
| @@ -138,6 +141,16 @@ function sizeOptions(chosen: string): { value: string; label: string; selected?: | ||
| 138 | 141 | })); |
| 139 | 142 | } |
| 140 | 143 | |
| 144 | /** The templates in order, Blank first; the one asked for chosen (Blank when it is not one of them). */ | |
| 145 | function templateOptions(chosen: string): { value: string; label: string; selected?: boolean }[] { | |
| 146 | const at = TEMPLATES.some((t) => t.id === chosen) ? chosen : DEFAULT_TEMPLATE; | |
| 147 | return TEMPLATES.map((t) => ({ | |
| 148 | value: t.id, | |
| 149 | label: `${t.title}: ${lower(t.says)}`, | |
| 150 | selected: t.id === at, | |
| 151 | })); | |
| 152 | } | |
| 153 | ||
| 141 | 154 | function formView( |
| 142 | 155 | ctx: Ctx, |
| 143 | 156 | user: User | undefined, |
| @@ -312,6 +325,13 @@ function formView( | ||
| 312 | 325 | hint: "For the key's whole life; the child cannot raise it. Needed with a key.", |
| 313 | 326 | ...(errors["budgetUsd"] ? { error: errors["budgetUsd"] } : {}), |
| 314 | 327 | })} |
| 328 | ${selectField({ | |
| 329 | label: "Template", | |
| 330 | name: "template", | |
| 331 | hint: "What the child starts from besides the apps you fork. Blank is the default.", | |
| 332 | options: templateOptions(values.template), | |
| 333 | ...(errors["template"] ? { error: errors["template"] } : {}), | |
| 334 | })} | |
| 315 | 335 | ${selectField({ |
| 316 | 336 | label: "Size (optional)", |
| 317 | 337 | name: "size", |
| @@ -342,7 +362,7 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record | ||
| 342 | 362 | const copied = (body["copied"] as string[] | undefined) ?? []; |
| 343 | 363 | const bytes = Number(body["bytes"] ?? 0); |
| 344 | 364 | const note = body["keyNote"]; |
| 345 | const plan = body["plan"] as { budgetUsd?: number | null } | undefined; | |
| 365 | const plan = body["plan"] as { budgetUsd?: number | null; template?: string } | undefined; | |
| 346 | 366 | const budget = plan?.budgetUsd ?? null; |
| 347 | 367 | const address = childUrlOf(ctx.app, values.name); |
| 348 | 368 | return card( |
| @@ -356,6 +376,9 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record | ||
| 356 | 376 | ? "You keep owner access after it is claimed; the claim page tells them." |
| 357 | 377 | : "You leave once it is claimed; they can invite you back."} |
| 358 | 378 | </li>`} |
| 379 | ${plan?.template === undefined | |
| 380 | ? null | |
| 381 | : html`<li>It starts from the <b>${templateOf(plan.template).title}</b> template.</li>`} | |
| 359 | 382 | ${budget === null |
| 360 | 383 | ? null |
| 361 | 384 | : html`<li>Its crew runs on the key you give, up to $${budget} for the key's whole life.</li>`} |
| @@ -451,6 +474,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 451 | 474 | budgetUsd: field(form, "budgetUsd").trim(), |
| 452 | 475 | modelEndpoint: field(form, "modelEndpoint").trim(), |
| 453 | 476 | size: field(form, "size").trim() || DEFAULT_SIZE, |
| 477 | template: field(form, "template").trim() || DEFAULT_TEMPLATE, | |
| 454 | 478 | hadKey: field(form, "modelKey").trim() !== "", |
| 455 | 479 | }; |
| 456 | 480 | const key = field(form, "modelKey").trim(); |
| @@ -469,6 +493,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 469 | 493 | ...(values.budgetUsd === "" ? {} : { budgetUsd: Number(values.budgetUsd) }), |
| 470 | 494 | // Small is the default: it is not recorded, so the plan stays as it was. |
| 471 | 495 | ...(values.size === DEFAULT_SIZE ? {} : { size: values.size }), |
| 496 | ...(values.template === DEFAULT_TEMPLATE ? {} : { template: values.template }), | |
| 472 | 497 | dryRun: planOnly, |
| 473 | 498 | }); |
| 474 | 499 | if (door.status >= 400) |
test/new-forge.test.ts +26
| @@ -133,6 +133,32 @@ describe("+ New forge", () => { | ||
| 133 | 133 | expect(html).toContain("user accounts"); |
| 134 | 134 | }); |
| 135 | 135 | |
| 136 | test("the Template field lists Blank first and selected; starter reaches the plan from the door's own body; an unknown id is refused on the field", async () => { | |
| 137 | const page = (await (await get(admin, "/forges/new")).text()).replace(/\s+/g, " "); | |
| 138 | const at = page.indexOf('name="template"'); | |
| 139 | expect(at).toBeGreaterThan(0); | |
| 140 | const field = page.slice(at, page.indexOf("</select>", at)); | |
| 141 | expect(field).toMatch(/<option value="blank" selected >\s*Blank/); | |
| 142 | expect(field.indexOf('value="blank"')).toBeLessThan(field.indexOf('value="starter"')); | |
| 143 | expect(field).toContain("Starter app"); | |
| 144 | ||
| 145 | const plan = await post(admin, [["name", "tpl"], ["do", "plan"], ["template", "starter"]]); | |
| 146 | expect(plan.status).toBe(200); | |
| 147 | const planned = (await plan.text()).replace(/\s+/g, " "); | |
| 148 | expect(planned).toContain("starts from the <b>Starter app</b> template"); | |
| 149 | expect(planned).toMatch(/<option value="starter" selected >/); | |
| 150 | ||
| 151 | const blank = await post(admin, [["name", "tpl"], ["do", "plan"], ["template", "blank"]]); | |
| 152 | expect(await blank.text()).not.toContain("template.</li>"); | |
| 153 | ||
| 154 | const bad = await post(admin, [["name", "tpl"], ["do", "plan"], ["template", "nope"]]); | |
| 155 | expect(bad.status).toBe(422); | |
| 156 | const body = await bad.text(); | |
| 157 | expect(body).toContain("template must be blank or starter"); | |
| 158 | expect(body).toContain('value="tpl"'); | |
| 159 | expect(await children()).toBe(0); | |
| 160 | }); | |
| 161 | ||
| 136 | 162 | test("a post from another origin is refused, and nothing is made", async () => { |
| 137 | 163 | const res = await post( |
| 138 | 164 | admin, |
test/tui-newforge-template.test.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | /** | |
| 2 | * The terminal's new-forge screen offers the web form's Template, Blank first | |
| 3 | * (#7933), and the chosen one reaches the plan the door makes. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 12 | import { hostedPlacement } from "../src/spawn/placement.ts"; | |
| 13 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 14 | import { newForgeScreen } from "../src/tui/screens/newforge.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let dir: string; | |
| 22 | let app: App; | |
| 23 | let admin: User; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dir = await mkdtemp(join(tmpdir(), "forge-tui-newforge-template-")); | |
| 27 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 28 | const r = await createUser(app.db, { | |
| 29 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 30 | password: "a-good-passphrase", isAdmin: true, | |
| 31 | }); | |
| 32 | if (!r.ok) throw new Error(r.error); | |
| 33 | admin = r.value; | |
| 34 | useProbe(app, () => async () => true); | |
| 35 | usePlacement(app, hostedPlacement({ host: "forge.test" })); | |
| 36 | }); | |
| 37 | afterAll(async () => { | |
| 38 | app.db.close(); | |
| 39 | await rm(dir, { recursive: true, force: true }); | |
| 40 | }); | |
| 41 | ||
| 42 | const TO_TEMPLATE = "\t".repeat(10); // name … Size, then Template | |
| 43 | ||
| 44 | async function open(keys: string): Promise<string> { | |
| 45 | const terminal = headlessTerminal(120, 40); | |
| 46 | const { app: tui, session } = createForgeApp( | |
| 47 | app, | |
| 48 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: admin }, | |
| 49 | { terminal, exitHooks: false }, | |
| 50 | ); | |
| 51 | tui.start(); | |
| 52 | tui.ctx.push(newForgeScreen(session, tui.ctx)); | |
| 53 | try { | |
| 54 | await settleApp(tui); | |
| 55 | terminal.feed(keys); | |
| 56 | await settleApp(tui); | |
| 57 | return tui.screen.toText(); | |
| 58 | } finally { | |
| 59 | tui.stop?.(); | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | test("the Template field shows blank first, and right moves to starter and back", async () => { | |
| 64 | expect(await open(TO_TEMPLATE)).toContain("blank"); | |
| 65 | expect(await open(TO_TEMPLATE + "\x1b[C")).toContain("starter"); | |
| 66 | expect(await open(TO_TEMPLATE + "\x1b[C\x1b[C")).toContain("blank"); | |
| 67 | }); | |
| 68 | ||
| 69 | test("choosing starter puts the template in the plan, from the door's body", async () => { | |
| 70 | const screen = await open("tplstar" + TO_TEMPLATE + "\x1b[C\x10"); | |
| 71 | expect(screen).toContain("The plan"); | |
| 72 | expect(screen).toContain("Template: Starter app."); | |
| 73 | }); | |
| 74 | ||
| 75 | test("blank, chosen or left alone, names no template in the plan", async () => { | |
| 76 | for (const keys of ["tplblank\x10", "tplback" + TO_TEMPLATE + "\x1b[C\x1b[D\x10"]) { | |
| 77 | const screen = await open(keys); | |
| 78 | expect(screen).toContain("The plan"); | |
| 79 | expect(screen).not.toContain("Template:"); | |
| 80 | } | |
| 81 | }); |
new above
offline