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

Merged opened this

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

Merged

scout/forge:scout/7933-forges-new-offers-the-starter-templates → main 2 commits fork

  • Merged. scout/7933-forges-new-offers-the-starter-templates was merged into main as 42157f2.

Review

Sign in to review this change.
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
8484
8585 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`.
8686
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
8789 **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`.
8890
8991 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";
1010 export interface Template {
1111 readonly id: string;
1212 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. */
1414 readonly says: string;
1515 readonly source: "builtin" | "repo";
1616 /** The repository a `repo` template is read from, as `owner/name`. */
@@ -25,13 +25,13 @@ export const TEMPLATES: readonly Template[] = [
2525 {
2626 id: DEFAULT_TEMPLATE,
2727 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.",
2929 source: "builtin",
3030 },
3131 {
3232 id: "starter",
3333 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.",
3535 source: "builtin",
3636 },
3737 ];
@@ -43,6 +43,11 @@ export function parseTemplate(v: unknown): Result<string, string> {
4343 return err(`template must be ${ids.slice(0, -1).join(", ")} or ${ids[ids.length - 1]}`);
4444 }
4545
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
4651 /** What each template is, for a page or an agent to offer. */
4752 export function templatesView(): { templates: readonly Template[]; default: string } {
4853 return { templates: TEMPLATES, default: DEFAULT_TEMPLATE };
src/tui/screens/newforge.ts +10 −2
@@ -8,6 +8,7 @@
88 */
99
1010 import { DEFAULT_SIZE, SIZES, type Size } from "../../spawn/sizes.ts";
11 import { DEFAULT_TEMPLATE, TEMPLATES, templateOf } from "../../spawn/templates.ts";
1112 import { childUrlOf, spawnDoor } from "../../spawn/door.ts";
1213 import { quotaFull } from "../../spawn/quota.ts";
1314 import { FOLLOW_LINE, FOLLOW_WORDS } from "../../spawn/follow_words.ts";
@@ -32,6 +33,8 @@ interface Spec {
3233
3334 const SIZE_CHOICES = Object.keys(SIZES) as Size[];
3435
36 const TEMPLATE_CHOICES = TEMPLATES.map((t) => t.id);
37
3538 const SPECS: readonly Spec[] = [
3639 { key: "name", label: "Name", hint: "Lowercase letters, digits and dashes." },
3740 { 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[] = [
4346 { key: "budgetUsd", label: "Budget in dollars", hint: `${MIN_BUDGET_USD}–${MAX_BUDGET_USD}, for the key's whole life. Needed with a key.` },
4447 { key: "followMode", label: "Follows this forge's code", hint: FOLLOW_LINE },
4548 { 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 },
4650 ];
4751
4852 /** 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 {
7882 ...(val("budgetUsd") === "" ? {} : { budgetUsd: Number(val("budgetUsd")) }),
7983 // Small is the default: it is not recorded, as on the page.
8084 ...(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") }),
8187 dryRun,
8288 };
8389 };
@@ -121,6 +127,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen {
121127 const bytes = Number(body["bytes"] ?? 0);
122128 const budget = (body["plan"] as { budgetUsd?: number | null } | undefined)?.budgetUsd ?? null;
123129 const size = (body["plan"] as { size?: string } | undefined)?.size;
130 const template = (body["plan"] as { template?: string } | undefined)?.template;
124131 const mode = val("followMode") === "" ? "tell-me" : val("followMode");
125132 plan = [
126133 `A forge at ${childUrlOf(f.app, val("name"))}.`,
@@ -130,6 +137,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen {
130137 "Copied into it:",
131138 ...copied.map((c) => ` ${c}`),
132139 ...(size === undefined ? [] : [`Size: ${size}.`]),
140 ...(template === undefined ? [] : [`Template: ${templateOf(template).title}.`]),
133141 `About ${Math.max(1, Math.round(bytes / 1024))} KB. Nothing is made until you press enter.`,
134142 ...(typeof body["keyNote"] === "string" ? [body["keyNote"]] : []),
135143 ];
@@ -182,7 +190,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen {
182190 let cursor: { x: number; y: number } | null = null;
183191 SPECS.slice(first, first + rows).forEach((spec, n) => {
184192 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]!);
186194 const shown = spec.secret === true ? "•".repeat([...raw].length) : raw;
187195 const at = field(s, { x: area.x, y: area.y + n * 3, w: Math.min(70, area.w), h: 3 }, c, {
188196 label: spec.label,
@@ -222,7 +230,7 @@ export function newForgeScreen(f: Session, ctx: Ctx): Screen {
222230 if (choices !== undefined && step !== 0) {
223231 // A choice can be cycled or typed; both end in a value the door checks.
224232 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]!));
226234 values[k] = choices[(at + step + choices.length) % choices.length]!;
227235 error = undefined;
228236 return true;
src/web/pages/new-forge.ts +26 −1
@@ -24,6 +24,7 @@ import {
2424 MIN_BUDGET_USD,
2525 } from "../../spawn/plan.ts";
2626 import { DEFAULT_SIZE, SIZES, sizesView } from "../../spawn/sizes.ts";
27 import { DEFAULT_TEMPLATE, TEMPLATES, templateOf } from "../../spawn/templates.ts";
2728 import { FOLLOW_LABELS, FOLLOW_WORDS } from "../../spawn/follow_words.ts";
2829 import { notFoundPage, pageHead } from "../chrome.ts";
2930 import { field, readForm, type Form } from "../form.ts";
@@ -43,6 +44,7 @@ interface Values {
4344 readonly budgetUsd: string;
4445 readonly modelEndpoint: string;
4546 readonly size: string;
47 readonly template: string;
4648 /** The last post carried a key: it is never sent back, so it is asked for again. */
4749 readonly hadKey: boolean;
4850 }
@@ -59,6 +61,7 @@ const EMPTY: Values = {
5961 budgetUsd: "",
6062 modelEndpoint: "",
6163 size: DEFAULT_SIZE,
64 template: DEFAULT_TEMPLATE,
6265 hadKey: false,
6366 };
6467
@@ -138,6 +141,16 @@ function sizeOptions(chosen: string): { value: string; label: string; selected?:
138141 }));
139142 }
140143
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
141154 function formView(
142155 ctx: Ctx,
143156 user: User | undefined,
@@ -312,6 +325,13 @@ function formView(
312325 hint: "For the key's whole life; the child cannot raise it. Needed with a key.",
313326 ...(errors["budgetUsd"] ? { error: errors["budgetUsd"] } : {}),
314327 })}
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 })}
315335 ${selectField({
316336 label: "Size (optional)",
317337 name: "size",
@@ -342,7 +362,7 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record
342362 const copied = (body["copied"] as string[] | undefined) ?? [];
343363 const bytes = Number(body["bytes"] ?? 0);
344364 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;
346366 const budget = plan?.budgetUsd ?? null;
347367 const address = childUrlOf(ctx.app, values.name);
348368 return card(
@@ -356,6 +376,9 @@ function planView(ctx: Ctx, user: User | undefined, values: Values, body: Record
356376 ? "You keep owner access after it is claimed; the claim page tells them."
357377 : "You leave once it is claimed; they can invite you back."}
358378 </li>`}
379 ${plan?.template === undefined
380 ? null
381 : html`<li>It starts from the <b>${templateOf(plan.template).title}</b> template.</li>`}
359382 ${budget === null
360383 ? null
361384 : 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> {
451474 budgetUsd: field(form, "budgetUsd").trim(),
452475 modelEndpoint: field(form, "modelEndpoint").trim(),
453476 size: field(form, "size").trim() || DEFAULT_SIZE,
477 template: field(form, "template").trim() || DEFAULT_TEMPLATE,
454478 hadKey: field(form, "modelKey").trim() !== "",
455479 };
456480 const key = field(form, "modelKey").trim();
@@ -469,6 +493,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
469493 ...(values.budgetUsd === "" ? {} : { budgetUsd: Number(values.budgetUsd) }),
470494 // Small is the default: it is not recorded, so the plan stays as it was.
471495 ...(values.size === DEFAULT_SIZE ? {} : { size: values.size }),
496 ...(values.template === DEFAULT_TEMPLATE ? {} : { template: values.template }),
472497 dryRun: planOnly,
473498 });
474499 if (door.status >= 400)
test/new-forge.test.ts +26
@@ -133,6 +133,32 @@ describe("+ New forge", () => {
133133 expect(html).toContain("user accounts");
134134 });
135135
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
136162 test("a post from another origin is refused, and nothing is made", async () => {
137163 const res = await post(
138164 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs