Public: anyone can read this; the crew can change it.

A forge made with template X starts with X's files #8925

Merged opened this in answer to

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

Merged

torque/forge:torque/7535-a-forge-made-with-template-x-starts-with → main 3 commits fork

  • Merged. torque/7535-a-forge-made-with-template-x-starts-with was merged into main as f7a4164.

Review

Sign in to review this change.
5 files changed +240 −4
docs/SPAWN.md +1 −1
@@ -80,7 +80,7 @@ A spawn may also name a `size`: `small`, `medium` or `large` (`src/spawn/sizes.t
8080
8181 A template is a named starting point for a new forge's apps, listed in `src/spawn/templates.ts`: each has an `id`, a `title`, one sentence `says`, and a `source` — `builtin` (its files are this forge's code) or `repo` (read from `repo`, an `owner/name` on this forge; none yet, #7536). Built in are `blank`, the default and what a spawn has always made (the forked apps and an optional `fresh` one), and `starter`, the app `/apps/new` starts from (`scaffoldFiles`, `src/apps/scaffold.ts`).
8282
83 `POST /api/v1/forges` and the MCP `spawn` take an optional `template` id. An unknown id is a 422 on `template` naming the valid ids. Absent or `blank`, the plan is the one it always was; any other id is named in the plan, the ledger line, `parent.json` and the child's row in `GET /api/v1/forges` (absent there means `blank`). Because it is in the plan, a retry with another template is refused as a different child. For now the choice is recorded only: a `starter` child starts with the same files as a `blank` one until #7535 lays the template's files down.
83 `POST /api/v1/forges` and the MCP `spawn` take an optional `template` id. An unknown id is a 422 on `template` naming the valid ids. Absent or `blank`, the plan is the one it always was; any other id is named in the plan, the ledger line, `parent.json` and the child's row in `GET /api/v1/forges` (absent there means `blank`). Because it is in the plan, a retry with another template is refused as a different child. A `starter` child begins with the template's files (#7535, `src/spawn/template_birth.ts`): one commit, no history and no upstream, in a new private repo of the child's organisation named for the `fresh` sentence (which becomes its description; no second repo) or `app`. `parent.json` records `template: {id}`. Only tracked files travel, nothing from the "never copied" list; a retry finds the repo and leaves it.
8484
8585 `GET /api/v1/forges/templates` (no sign-in, ETag, cached for 5 minutes) and the MCP tool `templates` give the same answer: `{ templates, default }`, `blank` first.
8686
src/actions/spawn.ts +15 −2
@@ -37,6 +37,7 @@ import { createUser, getUserByName, type User } from "../core/users.ts";
3737 import { runGit } from "../git/env.ts";
3838 import { repoDir } from "../git/repos.ts";
3939 import { commitFiles } from "../git/write.ts";
40 import { birthFromTemplate, type TemplateMark } from "../spawn/template_birth.ts";
4041 import { sha256 } from "../kernel/hash.ts";
4142 import { secret } from "../kernel/id.ts";
4243 import { err, ok, type Result } from "../kernel/result.ts";
@@ -350,6 +351,7 @@ async function copyInto(
350351 org = made.value;
351352 }
352353
354 let templateMark: TemplateMark | undefined;
353355 const copiedRepos: { parent: Repo; child: Repo }[] = [];
354356 for (const a of plan.apps) {
355357 const [o, n] = a.ref.split("/") as [string, string];
@@ -388,7 +390,18 @@ async function copyInto(
388390
389391 // A fresh app, begun from the spawner's sentence: a README and the
390392 // smallest app that answers, committed once.
391 if (plan.fresh !== undefined) {
393 // With a template, the sentence describes the template's app instead:
394 // one repository, not two (`spawn/template_birth.ts`).
395 if (plan.template !== undefined) {
396 const born = await birthFromTemplate(child, org, {
397 template: plan.template,
398 ...(plan.fresh === undefined ? {} : { fresh: plan.fresh }),
399 direction: plan.direction,
400 taken: new Set(plan.apps.map((a) => a.ref.split("/")[1]!)),
401 });
402 if (!born.ok) return err(born.error);
403 templateMark = born.value;
404 } else if (plan.fresh !== undefined) {
392405 const made = await startFresh(child, org, plan.fresh, plan.direction);
393406 if (!made.ok) return err(made.error);
394407 }
@@ -470,7 +483,7 @@ async function copyInto(
470483 url: liveChildUrl(app, plan.name),
471484 keepAccess: plan.keepAccess,
472485 size: plan.size ?? DEFAULT_SIZE,
473 ...(plan.template === undefined ? {} : { template: plan.template }),
486 ...(plan.template === undefined ? {} : { template: templateMark ?? { id: plan.template } }),
474487 // Each app is a fork: where it came from, so the child can follow it.
475488 apps: plan.apps.map((a) => ({ repo: `${plan.org}/${a.ref.split("/")[1]}`, upstream: a.ref })),
476489 ...(plan.fresh === undefined ? {} : { fresh: `${plan.org}/${plan.fresh.name}` }),
src/spawn/template_birth.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * A forge made from a template begins with its files (#7535). The template
3 * is a copy of files at the moment of birth: one commit in a new private
4 * repository in the child's organisation, no history and no upstream. Only
5 * the template's tracked files travel — never a setting, secret, issue or
6 * run (`docs/SPAWN.md`, "never copied"). Once: a retry finds the repository
7 * with its commit and leaves it.
8 */
9
10 import type { App } from "../app.ts";
11 import { scaffoldFiles } from "../apps/scaffold.ts";
12 import { createRepo, getRepo, updateRepoSettings } from "../core/repos.ts";
13 import { runGit } from "../git/env.ts";
14 import { repoDir } from "../git/repos.ts";
15 import { commitFiles, type FileWrite } from "../git/write.ts";
16 import { err, ok, type Result } from "../kernel/result.ts";
17 import { freshName } from "./plan.ts";
18
19 /** What `parent.json` records of the template: where the child came from. */
20 export interface TemplateMark {
21 readonly id: string;
22 readonly repo?: string;
23 readonly tip?: string;
24 }
25
26 /** The files a built-in template holds, for the app's name and description. */
27 export function templateFiles(id: string, name: string, description: string): FileWrite[] | undefined {
28 if (id === "starter") return scaffoldFiles({ name, description });
29 return undefined;
30 }
31
32 /**
33 * Commit the template's files into a private repository of the child's
34 * organisation. The repository is named for the spawner's `fresh` sentence
35 * when given (which then becomes its description), else `app`.
36 * Returns what `parent.json` should record, or undefined for an unknown template.
37 */
38 export async function birthFromTemplate(
39 child: App,
40 org: { readonly id: string; readonly username: string },
41 input: {
42 readonly template: string;
43 readonly fresh?: { readonly name: string; readonly description: string };
44 readonly direction: string;
45 /** Names already taken by forked apps. */
46 readonly taken: ReadonlySet<string>;
47 },
48 ): Promise<Result<TemplateMark | undefined, string>> {
49 const description = input.fresh?.description ?? "";
50 const name = input.fresh?.name ?? freshName("app", new Set([...input.taken, "forge"]));
51 const files = templateFiles(input.template, name, description);
52 if (files === undefined) return ok(undefined);
53
54 let repo = getRepo(child.db, org.username, name);
55 if (!repo) {
56 const made = await createRepo(child.db, child.reposRoot, {
57 ownerId: org.id,
58 name,
59 visibility: "private",
60 description,
61 defaultBranch: "main",
62 });
63 if (!made.ok) return err(`could not make ${name}: ${made.error}`);
64 repo = made.value;
65 }
66 const dir = repoDir(child.reposRoot, org.username, name);
67 const has = await runGit(["rev-parse", "--verify", "-q", `refs/heads/${repo.defaultBranch}`], { cwd: dir });
68 if (!has.ok) {
69 const c = await commitFiles(dir, {
70 branch: repo.defaultBranch,
71 message: `Start from the ${input.template} template`,
72 files,
73 author: { name: "forge", email: "[email protected]" },
74 });
75 if (!c.ok) return err(`could not start ${name} from the ${input.template} template`);
76 }
77 if (input.direction !== "" && repo.autonomyDirection !== input.direction)
78 updateRepoSettings(child.db, repo, { autonomyDirection: input.direction });
79 return ok({ id: input.template });
80 }
test/spawn-template-api.test.ts +1 −1
@@ -135,7 +135,7 @@ describe("choosing a template for a new forge", () => {
135135 const ledger = await Bun.file(join(dataDir, "spawn", "tpl.json")).json();
136136 expect(ledger.template).toBe("starter");
137137 const parent = await Bun.file(join(childDirOf(app, body.bodyId), "parent.json")).json();
138 expect(parent.template).toBe("starter");
138 expect(parent.template).toEqual({ id: "starter" });
139139
140140 const list = (await (await fetch(`${base}/api/v1/forges`, { headers: { authorization: `Bearer ${rw}` } })).json()) as any;
141141 expect(list.items.find((c: any) => c.name === "tpl").template).toBe("starter");
test/spawn-template-birth.test.ts added +143
@@ -0,0 +1,143 @@
1 /**
2 * A forge made from a template begins with its files (#7535): `starter`
3 * commits the scaffold into one private repo, `blank` adds nothing, a retry
4 * adds nothing more, and nothing of the parent travels.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, readdir, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { childDirOf, spawnForge, type SpawnDeps } from "../src/actions/spawn.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { scaffoldFiles } from "../src/apps/scaffold.ts";
15 import { getAppByRepo } from "../src/core/apps.ts";
16 import { getRepo } from "../src/core/repos.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { createUser, type User } from "../src/core/users.ts";
19 import { repoDir } from "../src/git/repos.ts";
20 import { commitFiles } from "../src/git/write.ts";
21 import { firstBoot } from "../src/spawn/first-boot.ts";
22 import { hostedPlacement } from "../src/spawn/placement.ts";
23 import type { SpawnSource } from "../src/spawn/plan.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25
26 quietLogs();
27
28 const SENTENCE = "A booking page";
29 const source: SpawnSource = { catalog: [], briefs: [], subscriptions: [] };
30
31 describe("a forge made from a template", () => {
32 let dir = "";
33 let parent: App;
34 let alice: User;
35 let secret = "";
36 const deps: SpawnDeps = {
37 placement: hostedPlacement({ host: "forge.test" }),
38 request: new Request("https://forge.test/x", { method: "POST", headers: { origin: "https://forge.test" } }),
39 url: new URL("https://forge.test/x"),
40 };
41 const spawn = async (name: string, extra: Record<string, unknown>) => {
42 const r = await spawnForge(
43 parent,
44 alice,
45 { name, org: name, apps: [], carryBriefs: false, carrySubscriptions: false, ...extra },
46 source,
47 deps,
48 );
49 if (!r.ok) throw new Error(JSON.stringify(r.error));
50 return childDirOf(parent, r.value.bodyId);
51 };
52 const git = (childDir: string, org: string, repo: string, ...args: string[]) =>
53 Bun.spawnSync(["git", ...args], { cwd: repoDir(join(childDir, "repos"), org, repo) }).stdout.toString();
54 const repos = (childDir: string, org: string) => readdir(join(childDir, "repos", org)).catch(() => [] as string[]);
55
56 beforeAll(async () => {
57 dir = await mkdtemp(join(tmpdir(), "forge-template-birth-"));
58 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
59 const made = await createUser(parent.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", isAdmin: true });
60 if (!made.ok) throw new Error("user");
61 alice = made.value;
62 const repo = await createRepo(parent, alice, { name: "secrets-here" });
63 if (!repo.ok) throw new Error("repo");
64 await commitFiles(repoDir(parent.reposRoot, "alice", "secrets-here"), {
65 branch: "main",
66 message: "x",
67 files: [{ path: "README.md", content: "# x\n" }],
68 author: { name: "a", email: "[email protected]" },
69 });
70 const t = createToken(parent.db, alice.id, "ci", "rw");
71 secret = t.token;
72 }, 30_000);
73
74 afterAll(async () => {
75 parent.close();
76 await rm(dir, { recursive: true, force: true });
77 });
78
79 test("a forge made with starter has the starter's files", async () => {
80 const c = await spawn("kai", { template: "starter", fresh: SENTENCE });
81 expect(await repos(c, "kai")).toContain("a-booking-page.git");
82 expect(await repos(c, "kai").then((r) => r.filter((n) => n !== "forge" && n !== "forge.git"))).toHaveLength(1);
83 const want = scaffoldFiles({ name: "a-booking-page", description: SENTENCE }).map((f) => f.path).sort();
84 const got = git(c, "kai", "a-booking-page", "ls-tree", "-r", "--name-only", "main").trim().split("\n").sort();
85 expect(got).toEqual(want);
86 expect(git(c, "kai", "a-booking-page", "show", "main:README.md")).toContain(SENTENCE);
87 const child = createApp({ dataDir: c, port: 0 });
88 expect(getRepo(child.db, "kai", "a-booking-page")?.visibility).toBe("private");
89 child.close();
90 }, 60_000);
91
92 test("without a sentence the app is named app", async () => {
93 const c = await spawn("noa", { template: "starter" });
94 const want = scaffoldFiles({ name: "app", description: "" }).map((f) => f.path).sort();
95 expect(git(c, "noa", "app", "ls-tree", "-r", "--name-only", "main").trim().split("\n").sort()).toEqual(want);
96 }, 60_000);
97
98 test("a forge made with blank is unchanged", async () => {
99 const none = await spawn("blankone", {});
100 const blank = await spawn("blanktwo", { template: "blank" });
101 const left = (await repos(none, "blankone")).sort();
102 expect((await repos(blank, "blanktwo")).sort()).toEqual(left);
103 expect(left).not.toContain("app");
104 expect(left).not.toContain("app.git");
105 }, 60_000);
106
107 test("a retry makes no second template repo", async () => {
108 const input = { template: "starter", fresh: SENTENCE };
109 const c = await spawn("rae", input);
110 const before = await repos(c, "rae");
111 const commits = git(c, "rae", "a-booking-page", "rev-list", "--count", "main").trim();
112 const again = await spawn("rae", input);
113 expect(again).toBe(c);
114 expect(await repos(again, "rae")).toEqual(before);
115 expect(git(c, "rae", "a-booking-page", "rev-list", "--count", "main").trim()).toBe(commits);
116 expect(commits).toBe("1");
117 }, 60_000);
118
119 test("parent.json records the template", async () => {
120 const c = await spawn("pat", { template: "starter" });
121 const meta = await Bun.file(join(c, "parent.json")).json();
122 expect(meta.template).toEqual({ id: "starter" });
123 const b = await spawn("pib", {});
124 expect("template" in (await Bun.file(join(b, "parent.json")).json())).toBe(false);
125 }, 60_000);
126
127 test("the template repo holds no parent secrets", async () => {
128 const c = await spawn("sam", { template: "starter" });
129 const all = git(c, "sam", "app", "grep", "-I", "-e", "", "main").toString();
130 expect(all).not.toContain("secrets-here");
131 expect(all).not.toContain(secret);
132 expect(git(c, "sam", "app", "log", "--format=%an <%ae>", "main").trim()).toBe("forge <[email protected]>");
133 }, 60_000);
134
135 test("first boot enables the template app", async () => {
136 const c = await spawn("fay", { template: "starter", direction: "Run it" });
137 const child = createApp({ dataDir: c, port: 0 });
138 await firstBoot(child);
139 const repo = getRepo(child.db, "fay", "app")!;
140 expect(getAppByRepo(child.db, repo.id)).toBeDefined();
141 child.close();
142 }, 60_000);
143 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs