Public: anyone can read this; the crew can change it.
A forge made with template X starts with X's files #8925
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7535-a-forge-made-with-template-x-starts-with → main 3 commits fork
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 | ||
| 80 | 80 | |
| 81 | 81 | 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`). |
| 82 | 82 | |
| 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. | |
| 84 | 84 | |
| 85 | 85 | `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. |
| 86 | 86 |
src/actions/spawn.ts +15 −2
| @@ -37,6 +37,7 @@ import { createUser, getUserByName, type User } from "../core/users.ts"; | ||
| 37 | 37 | import { runGit } from "../git/env.ts"; |
| 38 | 38 | import { repoDir } from "../git/repos.ts"; |
| 39 | 39 | import { commitFiles } from "../git/write.ts"; |
| 40 | import { birthFromTemplate, type TemplateMark } from "../spawn/template_birth.ts"; | |
| 40 | 41 | import { sha256 } from "../kernel/hash.ts"; |
| 41 | 42 | import { secret } from "../kernel/id.ts"; |
| 42 | 43 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -350,6 +351,7 @@ async function copyInto( | ||
| 350 | 351 | org = made.value; |
| 351 | 352 | } |
| 352 | 353 | |
| 354 | let templateMark: TemplateMark | undefined; | |
| 353 | 355 | const copiedRepos: { parent: Repo; child: Repo }[] = []; |
| 354 | 356 | for (const a of plan.apps) { |
| 355 | 357 | const [o, n] = a.ref.split("/") as [string, string]; |
| @@ -388,7 +390,18 @@ async function copyInto( | ||
| 388 | 390 | |
| 389 | 391 | // A fresh app, begun from the spawner's sentence: a README and the |
| 390 | 392 | // 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) { | |
| 392 | 405 | const made = await startFresh(child, org, plan.fresh, plan.direction); |
| 393 | 406 | if (!made.ok) return err(made.error); |
| 394 | 407 | } |
| @@ -470,7 +483,7 @@ async function copyInto( | ||
| 470 | 483 | url: liveChildUrl(app, plan.name), |
| 471 | 484 | keepAccess: plan.keepAccess, |
| 472 | 485 | size: plan.size ?? DEFAULT_SIZE, |
| 473 | ...(plan.template === undefined ? {} : { template: plan.template }), | |
| 486 | ...(plan.template === undefined ? {} : { template: templateMark ?? { id: plan.template } }), | |
| 474 | 487 | // Each app is a fork: where it came from, so the child can follow it. |
| 475 | 488 | apps: plan.apps.map((a) => ({ repo: `${plan.org}/${a.ref.split("/")[1]}`, upstream: a.ref })), |
| 476 | 489 | ...(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", () => { | ||
| 135 | 135 | const ledger = await Bun.file(join(dataDir, "spawn", "tpl.json")).json(); |
| 136 | 136 | expect(ledger.template).toBe("starter"); |
| 137 | 137 | 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" }); | |
| 139 | 139 | |
| 140 | 140 | const list = (await (await fetch(`${base}/api/v1/forges`, { headers: { authorization: `Bearer ${rw}` } })).json()) as any; |
| 141 | 141 | 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 | }); |
new above
offline