Public: anyone can read this; the crew can change it.
/forges/new gives a client's forge the owner's chosen apps as forks, in one form and one link #1769
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1608-forges-new-gives-a-client-s-forge-the-ow → main 4 commits fork 3 threads · 3 unresolved
README.md +2
| @@ -151,6 +151,8 @@ Unset, only this machine may say so, and the named header is never read. Everyth | ||
| 151 | 151 | instance _is_ lives under the data directory — the SQLite index, the bare |
| 152 | 152 | repos, and the SSH host key, in plain layout. Tar it up and it moves. |
| 153 | 153 | |
| 154 | `/forges/new`, `POST /api/v1/forges` and the MCP `spawn` make a client's forge from forks of the apps you pick, plus optionally one fresh app from a one-line description (`fresh`, at most 200 characters), and end in one claim link; see `docs/SPAWN.md`. | |
| 155 | ||
| 154 | 156 | `FORGE_PARENT_URL` names the forge this one was spawned from. It must be |
| 155 | 157 | `https://` with no credentials or query (the forge refuses to boot otherwise). |
| 156 | 158 | The parent serves `/releases.json` as `{"releases":[{"name","url"}]}`, newest |
docs/SPAWN.md +4
| @@ -48,6 +48,10 @@ Spawn makes the child's `secret.key` and SSH host key in the child's own directo | ||
| 48 | 48 | |
| 49 | 49 | A spawner may give the child one credential: `modelKey`, an API-billed key (`sk-ant-api…`), with `budgetUsd`, a whole-life budget in dollars from 1 to 1000. A subscription token (`sk-ant-oat…`) is refused, because it is a person's own plan. The key never enters the plan, the dry run, the answer, the ledger or the idempotency table. In the child it is sealed with the child's own key as the account `starter` and granted to its organisation's crew. The budget is locked on that account (`lockBudget`): it is enforced where every run picks its account, whoever started the run and on whichever repository, and the child's admin cannot raise it, only add their own key. A retry with a different key is refused; a key is given once. Said plainly in every answer that carries a key (`keyNote`): the budget holds while this forge hosts the child, whose admin has no shell or database access; on a machine the child's owner controls, only a spend limit set at the provider binds the key, so use a key from a workspace with its own spend limit. The MCP `spawn` tool refuses `modelKey`, so a secret never passes through an agent's conversation. With no key the child is in a clean state: its pages say the crew has no model key yet and where an admin adds one. |
| 50 | 50 | |
| 51 | ## A client's forge in one form (#1608) | |
| 52 | ||
| 53 | `/forges/new` is the whole path: a name, the owner's apps to fork, optionally one fresh app from a one-line description (`fresh`, at most 200 characters, one line), a budget with a model key; it ends in one claim link, shown once. Each picked app is a fork: its full history is copied and `parent.json` lists `apps: [{repo, upstream}]` so the child knows what it follows. A fresh app is a private repository in the child with a README, a `forge.json` and a tiny `index.ts` that answers `/` with the sentence, escaped; its name is the sentence's slug, never one of the forked names. A custom domain is not part of this: the child is reached at `<name>--<host>` and nothing else is promised. The same field is on `POST /api/v1/forges` and the MCP `spawn` (`fresh`). It is absent from the plan when unsaid, so older plan hashes are unchanged. | |
| 54 | ||
| 51 | 55 | ## Choices |
| 52 | 56 | |
| 53 | 57 | - Following across forges is HTTPS git with scoped tokens, because hosted |
scripts/gate.ts +1
| @@ -197,6 +197,7 @@ export const GATE: readonly string[] = [ | ||
| 197 | 197 | "test/spawn-api.test.ts", |
| 198 | 198 | "test/spawn-child-apps.test.ts", |
| 199 | 199 | "test/spawn-first-boot.test.ts", |
| 200 | "test/spawn-fresh-forks.test.ts", | |
| 200 | 201 | "test/spawn-keys.test.ts", |
| 201 | 202 | "test/web-apps.test.ts", |
| 202 | 203 | "test/web-ui.test.ts", |
src/actions/spawn.ts +69
| @@ -35,6 +35,7 @@ import { | ||
| 35 | 35 | import { createUser, getUserByName, type User } from "../core/users.ts"; |
| 36 | 36 | import { runGit } from "../git/env.ts"; |
| 37 | 37 | import { repoDir } from "../git/repos.ts"; |
| 38 | import { commitFiles } from "../git/write.ts"; | |
| 38 | 39 | import { sha256 } from "../kernel/hash.ts"; |
| 39 | 40 | import { secret } from "../kernel/id.ts"; |
| 40 | 41 | import { err, ok, type Result } from "../kernel/result.ts"; |
| @@ -344,6 +345,13 @@ async function copyInto( | ||
| 344 | 345 | copiedRepos.push({ parent, child: repo }); |
| 345 | 346 | } |
| 346 | 347 | |
| 348 | // A fresh app, begun from the spawner's sentence: a README and the | |
| 349 | // smallest app that answers, committed once. | |
| 350 | if (plan.fresh !== undefined) { | |
| 351 | const made = await startFresh(child, org, plan.fresh, plan.direction); | |
| 352 | if (!made.ok) return err(made.error); | |
| 353 | } | |
| 354 | ||
| 347 | 355 | // The forge's own code, forked into the child with its full history, |
| 348 | 356 | // so every child starts from the forge and can follow its parent. |
| 349 | 357 | // A cross-forge fork: the history is copied by git, and the upstream |
| @@ -419,6 +427,9 @@ async function copyInto( | ||
| 419 | 427 | // The child's own address: <name>--<parent's host>. |
| 420 | 428 | url: liveChildUrl(app, plan.name), |
| 421 | 429 | keepAccess: plan.keepAccess, |
| 430 | // Each app is a fork: where it came from, so the child can follow it. | |
| 431 | apps: plan.apps.map((a) => ({ repo: `${plan.org}/${a.ref.split("/")[1]}`, upstream: a.ref })), | |
| 432 | ...(plan.fresh === undefined ? {} : { fresh: `${plan.org}/${plan.fresh.name}` }), | |
| 422 | 433 | // The forge's code, forked into the child: where it came from, so |
| 423 | 434 | // the child can follow its parent's updates. |
| 424 | 435 | ...(forgeCode.value === undefined |
| @@ -434,6 +445,64 @@ async function copyInto( | ||
| 434 | 445 | } |
| 435 | 446 | } |
| 436 | 447 | |
| 448 | /** The smallest app that runs: answers `/` with the sentence, escaped. */ | |
| 449 | function freshFiles(description: string): { path: string; content: string }[] { | |
| 450 | const text = JSON.stringify(description); | |
| 451 | const html = JSON.stringify(`<!doctype html><meta charset=utf-8><title>app</title><p>`); | |
| 452 | return [ | |
| 453 | { path: "README.md", content: `# A fresh app\n\n${description.replace(/[\r\n]+/g, " ").replace(/[\\`*_{}\[\]<>()#+\-.!|~]/g, "\\$&")}\n` }, | |
| 454 | { path: "forge.json", content: JSON.stringify({ app: { run: "bun run index.ts", health: "/" } }, null, 2) + "\n" }, | |
| 455 | { | |
| 456 | path: "index.ts", | |
| 457 | content: `const text = ${text}; | |
| 458 | const esc = (s: string) => s.replace(/[&<>"']/g, (c) => "&#" + c.charCodeAt(0) + ";"); | |
| 459 | Bun.serve({ | |
| 460 | port: Number(process.env["PORT"] ?? 3000), | |
| 461 | fetch: () => | |
| 462 | new Response(${html} + esc(text) + "</p>", { | |
| 463 | headers: { "content-type": "text/html; charset=utf-8" }, | |
| 464 | }), | |
| 465 | }); | |
| 466 | `, | |
| 467 | }, | |
| 468 | ]; | |
| 469 | } | |
| 470 | ||
| 471 | /** A repository in the child's organisation holding a fresh app; a retry finds it and leaves it. */ | |
| 472 | async function startFresh( | |
| 473 | child: App, | |
| 474 | org: { readonly id: string; readonly username: string }, | |
| 475 | fresh: { readonly name: string; readonly description: string }, | |
| 476 | direction: string, | |
| 477 | ): Promise<Result<true, string>> { | |
| 478 | let repo = getRepo(child.db, org.username, fresh.name); | |
| 479 | if (!repo) { | |
| 480 | const made = await createRepo(child.db, child.reposRoot, { | |
| 481 | ownerId: org.id, | |
| 482 | name: fresh.name, | |
| 483 | visibility: "private", | |
| 484 | description: fresh.description, | |
| 485 | defaultBranch: "main", | |
| 486 | }); | |
| 487 | if (!made.ok) return err(`could not make ${fresh.name}: ${made.error}`); | |
| 488 | repo = made.value; | |
| 489 | } | |
| 490 | const dir = repoDir(child.reposRoot, org.username, fresh.name); | |
| 491 | const has = await runGit(["rev-parse", "--verify", "-q", `refs/heads/${repo.defaultBranch}`], { cwd: dir }); | |
| 492 | if (!has.ok) { | |
| 493 | const c = await commitFiles(dir, { | |
| 494 | branch: repo.defaultBranch, | |
| 495 | message: "A fresh app", | |
| 496 | files: freshFiles(fresh.description), | |
| 497 | author: { name: "forge", email: "[email protected]" }, | |
| 498 | }); | |
| 499 | if (!c.ok) return err(`could not start ${fresh.name}`); | |
| 500 | } | |
| 501 | if (direction !== "" && repo.autonomyDirection !== direction) | |
| 502 | updateRepoSettings(child.db, repo, { autonomyDirection: direction }); | |
| 503 | return ok(true); | |
| 504 | } | |
| 505 | ||
| 437 | 506 | /** |
| 438 | 507 | * The parent's own code, forked into the child's organisation with its full |
| 439 | 508 | * history. Returns the parent's `owner/name` when forked, undefined when the |
src/http/mcp.ts +5
| @@ -966,6 +966,11 @@ const TOOLS: readonly Tool[] = [ | ||
| 966 | 966 | description: |
| 967 | 967 | "What the child is for, in your words (at most 2000 characters): its apps' direction, opened as the planner's first issue when the child first boots.", |
| 968 | 968 | }, |
| 969 | fresh: { | |
| 970 | type: "string", | |
| 971 | description: | |
| 972 | "One line (at most 200 characters) on a fresh app to start in the child beside the copied ones: it gets a README and a small app that runs.", | |
| 973 | }, | |
| 969 | 974 | keepAccess: { |
| 970 | 975 | type: "boolean", |
| 971 | 976 | description: |
src/spawn/door.ts +1
| @@ -232,6 +232,7 @@ export async function spawnDoor( | ||
| 232 | 232 | // by whoever opens the link. |
| 233 | 233 | keepAccess: anon ? false : raw["keepAccess"], |
| 234 | 234 | budgetUsd: raw["budgetUsd"], |
| 235 | fresh: raw["fresh"], | |
| 235 | 236 | ...(anon ? {} : { for: raw["for"] }), |
| 236 | 237 | }; |
| 237 | 238 |
src/spawn/plan.ts +44
| @@ -29,6 +29,8 @@ export const KEY_BUDGET_NOTE = | ||
| 29 | 29 | "The budget holds while this forge hosts the child: its admin cannot raise it. On a machine the child's owner controls, only a spend limit set at the provider binds the key, so use a key from a workspace with its own spend limit."; |
| 30 | 30 | export const MAX_BUDGET_USD = 1000; |
| 31 | 31 | |
| 32 | /** One line saying what a fresh app is. */ | |
| 33 | export const MAX_FRESH_CHARS = 200; | |
| 32 | 34 | const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/; |
| 33 | 35 | const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/; |
| 34 | 36 | |
| @@ -62,6 +64,8 @@ export interface SpawnInput { | ||
| 62 | 64 | * The key itself never enters the plan: only that there is one, capped. |
| 63 | 65 | */ |
| 64 | 66 | readonly budgetUsd?: unknown; |
| 67 | /** One line describing a fresh app to start in the child, beside the forked ones; optional. */ | |
| 68 | readonly fresh?: unknown; | |
| 65 | 69 | } |
| 66 | 70 | |
| 67 | 71 | /** What the parent has to offer. Extra fields are ignored, never copied. */ |
| @@ -78,6 +82,12 @@ export interface PlannedApp { | ||
| 78 | 82 | readonly bytes: number; |
| 79 | 83 | } |
| 80 | 84 | |
| 85 | /** An app begun from a sentence, not forked from anything. */ | |
| 86 | export interface PlannedFresh { | |
| 87 | readonly name: string; | |
| 88 | readonly description: string; | |
| 89 | } | |
| 90 | ||
| 81 | 91 | export interface SpawnPlan { |
| 82 | 92 | readonly name: string; |
| 83 | 93 | readonly org: string; |
| @@ -92,6 +102,8 @@ export interface SpawnPlan { | ||
| 92 | 102 | readonly budgetUsd: number | null; |
| 93 | 103 | /** Whom the child is for, for the parent's client list; absent when unsaid. */ |
| 94 | 104 | readonly forWhom?: string; |
| 105 | /** A fresh app, present only when asked for. */ | |
| 106 | readonly fresh?: PlannedFresh; | |
| 95 | 107 | } |
| 96 | 108 | |
| 97 | 109 | export interface DryRun { |
| @@ -116,6 +128,20 @@ export const LEFT_BEHIND: readonly string[] = [ | ||
| 116 | 128 | const refuse = (field: string, reason: string) => err<Refusal>({ field, reason }); |
| 117 | 129 | const byText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); |
| 118 | 130 | |
| 131 | /** The repository name a sentence earns: its first words as a slug, never one of `taken`. */ | |
| 132 | export function freshName(description: string, taken: ReadonlySet<string>): string { | |
| 133 | let slug = description | |
| 134 | .toLowerCase() | |
| 135 | .replace(/[^a-z0-9]+/g, "-") | |
Minor, not blocking: a sentence that is mostly non-ASCII becomes a short slug ("Café" → |
||
| 136 | .replace(/^-+/, "") | |
| 137 | .slice(0, 30) | |
| 138 | .replace(/-+$/, ""); | |
| 139 | if (slug === "") slug = "app"; | |
| 140 | let name = slug; | |
| 141 | for (let i = 2; taken.has(name); i++) name = `${slug}-${i}`; | |
| 142 | return name; | |
| 143 | } | |
| 144 | ||
| 119 | 145 | export function planSpawn( |
| 120 | 146 | input: SpawnInput, |
| 121 | 147 | source: SpawnSource, |
| @@ -197,6 +223,20 @@ export function planSpawn( | ||
| 197 | 223 | if (forWhom.length > MAX_FOR_CHARS) return refuse("for", `at most ${MAX_FOR_CHARS} characters`); |
| 198 | 224 | } |
| 199 | 225 | |
| 226 | let fresh: PlannedFresh | undefined; | |
| 227 | if (input.fresh !== undefined && input.fresh !== null && input.fresh !== "") { | |
| 228 | if (typeof input.fresh !== "string") return refuse("fresh", "must be text"); | |
| 229 | const description = input.fresh.trim(); | |
| 230 | if (description === "") return refuse("fresh", "say in one line what the app is"); | |
| 231 | if (description.length > MAX_FRESH_CHARS) return refuse("fresh", `at most ${MAX_FRESH_CHARS} characters`); | |
| 232 | if (/[\u0000-\u001f\u007f]/.test(description)) return refuse("fresh", "must be one line"); | |
| 233 | // `forge` is the fixed name of the forge-code fork, so a fresh app never takes it. | |
| 234 | fresh = { | |
outdated
The taken set leaves out |
||
| 235 | name: freshName(description, new Set(["forge", ...apps.map((a) => a.ref.split("/")[1]!.toLowerCase())])), | |
| 236 | description, | |
| 237 | }; | |
| 238 | } | |
| 239 | ||
| 200 | 240 | // `forWhom` only when said, so a plan without it hashes as it always did. |
| 201 | 241 | return ok({ |
| 202 | 242 | name, |
| @@ -208,6 +248,7 @@ export function planSpawn( | ||
| 208 | 248 | keepAccess, |
| 209 | 249 | budgetUsd, |
| 210 | 250 | ...(forWhom === "" ? {} : { forWhom }), |
| 251 | ...(fresh === undefined ? {} : { fresh }), | |
| 211 | 252 | }); |
| 212 | 253 | } |
| 213 | 254 | |
| @@ -217,6 +258,9 @@ const enc = new TextEncoder(); | ||
| 217 | 258 | export function dryRun(plan: SpawnPlan, source: SpawnSource): DryRun { |
| 218 | 259 | const copied = [ |
| 219 | 260 | ...plan.apps.map((a) => `app ${a.ref} (full history, forge.json)`), |
| 261 | ...(plan.fresh === undefined | |
| 262 | ? [] | |
| 263 | : [`a fresh app ${plan.fresh.name}: ${plan.fresh.description}`]), | |
| 220 | 264 | ...plan.briefs.map((b) => `crew brief for ${b.agent}`), |
| 221 | 265 | ...plan.subscriptions.map((s) => `subscription ${s} (name only)`), |
| 222 | 266 | ...(plan.direction === "" ? [] : ["your words, as the child's direction"]), |
src/web/pages/new-forge.ts +13 −1
| @@ -17,6 +17,7 @@ import { childHost, childUrlOf, noRoom, spawnDoor, type Door } from "../../spawn | ||
| 17 | 17 | import { |
| 18 | 18 | LEFT_BEHIND, |
| 19 | 19 | MAX_FOR_CHARS, |
| 20 | MAX_FRESH_CHARS, | |
| 20 | 21 | MAX_BUDGET_USD, |
| 21 | 22 | MIN_BUDGET_USD, |
| 22 | 23 | } from "../../spawn/plan.ts"; |
| @@ -32,6 +33,7 @@ interface Values { | ||
| 32 | 33 | readonly direction: string; |
| 33 | 34 | readonly apps: readonly string[]; |
| 34 | 35 | readonly crew: readonly string[]; |
| 36 | readonly fresh: string; | |
| 35 | 37 | readonly keepAccess: boolean; |
| 36 | 38 | readonly budgetUsd: string; |
| 37 | 39 | readonly modelEndpoint: string; |
| @@ -45,6 +47,7 @@ const EMPTY: Values = { | ||
| 45 | 47 | direction: "", |
| 46 | 48 | apps: [], |
| 47 | 49 | crew: [], |
| 50 | fresh: "", | |
| 48 | 51 | keepAccess: false, |
| 49 | 52 | budgetUsd: "", |
| 50 | 53 | modelEndpoint: "", |
| @@ -153,7 +156,7 @@ function formView( | ||
| 153 | 156 | ...(errors["direction"] ? { error: errors["direction"] } : {}), |
| 154 | 157 | })} |
| 155 | 158 | <fieldset class="field" ${errors["apps"] ? html`data-invalid` : null}> |
| 156 | <legend>Apps to copy, with their history</legend> | |
| 159 | <legend>Apps to fork, with their history</legend> | |
| 157 | 160 | ${checks( |
| 158 | 161 | "apps", |
| 159 | 162 | repos.map((r) => ({ |
| @@ -166,6 +169,13 @@ function formView( | ||
| 166 | 169 | ? html`<span class="error" id="f-apps-err">${errors["apps"]}</span>` |
| 167 | 170 | : null} |
| 168 | 171 | </fieldset> |
| 172 | ${formField({ | |
| 173 | label: "Or a fresh app (optional)", | |
| 174 | name: "fresh", | |
| 175 | value: values.fresh, | |
| 176 | hint: `One line on what it is; the child gets a small app to start from (at most ${MAX_FRESH_CHARS} characters).`, | |
| 177 | ...(errors["fresh"] ? { error: errors["fresh"] } : {}), | |
| 178 | })} | |
| 169 | 179 | ${crew.length === 0 |
| 170 | 180 | ? null |
| 171 | 181 | : html`<fieldset class="field"> |
| @@ -349,6 +359,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 349 | 359 | direction: field(form, "direction"), |
| 350 | 360 | apps: all(form, "apps"), |
| 351 | 361 | crew: all(form, "crew"), |
| 362 | fresh: field(form, "fresh").trim(), | |
| 352 | 363 | keepAccess: asBool(form, "keepAccess"), |
| 353 | 364 | budgetUsd: field(form, "budgetUsd").trim(), |
| 354 | 365 | modelEndpoint: field(form, "modelEndpoint").trim(), |
| @@ -361,6 +372,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> { | ||
| 361 | 372 | apps: values.apps, |
| 362 | 373 | crew: values.crew, |
| 363 | 374 | keepAccess: values.keepAccess, |
| 375 | ...(values.fresh === "" ? {} : { fresh: values.fresh }), | |
| 364 | 376 | ...(values.forWhom === "" ? {} : { for: values.forWhom }), |
| 365 | 377 | ...(values.direction.trim() === "" ? {} : { direction: values.direction }), |
| 366 | 378 | ...(key === "" ? {} : { modelKey: key }), |
test/spawn-api.test.ts +13
| @@ -119,6 +119,19 @@ describe("spawn over the API and MCP", () => { | ||
| 119 | 119 | expect(placed).toBe(0); |
| 120 | 120 | }); |
| 121 | 121 | |
| 122 | test("a fresh app is planned through the API and the MCP spawn; a bad sentence is refused", async () => { | |
| 123 | const api = (await (await post(rw, { name: "fresh1", apps: ["alice/notes"], fresh: "A booking page" })).json()) as any; | |
| 124 | expect(api.plan.fresh).toEqual({ name: "a-booking-page", description: "A booking page" }); | |
| 125 | expect(api.copied.join("\n")).toContain("a fresh app a-booking-page"); | |
| 126 | const m = await mcp(rw, { name: "fresh1", fresh: "A booking page" }); | |
| 127 | expect(m.error).toBe(false); | |
| 128 | expect(m.text).toContain("a-booking-page"); | |
| 129 | const bad = await post(rw, { name: "fresh1", fresh: "one\ntwo" }); | |
| 130 | expect(bad.status).toBe(422); | |
| 131 | expect(((await bad.json()) as any).message).toContain("fresh: must be one line"); | |
| 132 | expect((await post(rw, { name: "fresh1", fresh: "x".repeat(201) })).status).toBe(422); | |
| 133 | }); | |
| 134 | ||
| 122 | 135 | test("dryRun: false makes the child through the placement, and the ledger lists it", async () => { |
| 123 | 136 | const res = await post(rw, { name: "kid", apps: ["alice/notes"], crew: ["scout"], dryRun: false }); |
| 124 | 137 | expect(res.status).toBe(201); |
test/spawn-fresh-forks.test.ts added +155
| @@ -0,0 +1,155 @@ | ||
| 1 | /** | |
| 2 | * A client's forge in one form (#1608): two forked apps and one fresh from a | |
| 3 | * sentence, and one claim link. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, readdir, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { childDirOf } from "../src/actions/spawn.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { commitFiles } from "../src/git/write.ts"; | |
| 15 | import { repoDir } from "../src/git/repos.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { freshName, planSpawn } from "../src/spawn/plan.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | const PASSWORD = "a-long-fresh-forks-passphrase"; | |
| 23 | ||
| 24 | describe("a forge with forked and fresh apps", () => { | |
| 25 | let dir = ""; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | let cookie = ""; | |
| 29 | ||
| 30 | const post = (form: [string, string][]) => | |
| 31 | fetch(`${servers.url}/forges/new`, { | |
| 32 | method: "POST", | |
| 33 | headers: { cookie, origin: "https://forge.test" }, | |
| 34 | body: new URLSearchParams(form), | |
| 35 | redirect: "manual", | |
| 36 | }); | |
| 37 | const base: [string, string][] = [ | |
| 38 | ["name", "kai"], | |
| 39 | ["apps", "alice/bakery"], | |
| 40 | ["apps", "alice/shop"], | |
| 41 | ]; | |
| 42 | ||
| 43 | beforeAll(async () => { | |
| 44 | dir = await mkdtemp(join(tmpdir(), "forge-fresh-forks-")); | |
| 45 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 46 | const a = await createUser(app.db, { | |
| 47 | trusted: true, | |
| 48 | username: "alice", | |
| 49 | email: "[email protected]", | |
| 50 | password: PASSWORD, | |
| 51 | isAdmin: true, | |
| 52 | }); | |
| 53 | if (!a.ok) throw new Error("user"); | |
| 54 | for (const name of ["bakery", "shop"]) { | |
| 55 | const r = await createRepo(app, a.value, { name }); | |
| 56 | if (!r.ok) throw new Error("repo"); | |
| 57 | const c = await commitFiles(repoDir(app.reposRoot, "alice", name), { | |
| 58 | branch: "main", | |
| 59 | message: name, | |
| 60 | files: [{ path: "README.md", content: `# ${name}\n` }], | |
| 61 | author: { name: "a", email: "[email protected]" }, | |
| 62 | }); | |
| 63 | if (!c.ok) throw new Error("commit"); | |
| 64 | } | |
| 65 | servers = await serve(app); | |
| 66 | const res = await fetch(`${servers.url}/signin`, { | |
| 67 | method: "POST", | |
| 68 | body: new URLSearchParams({ username: "alice", password: PASSWORD }), | |
| 69 | redirect: "manual", | |
| 70 | }); | |
| 71 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 72 | }, 30_000); | |
| 73 | ||
| 74 | afterAll(async () => { | |
| 75 | servers.stop(); | |
| 76 | app.close(); | |
| 77 | await rm(dir, { recursive: true, force: true }); | |
| 78 | }); | |
| 79 | ||
| 80 | test("the plan names the fresh app and the real address, and makes nothing", async () => { | |
| 81 | const res = await post([ | |
| 82 | ...base, | |
| 83 | ["fresh", "A booking page for Kai's shop"], | |
| 84 | ["do", "plan"], | |
| 85 | ]); | |
| 86 | const html = (await res.text()).replace(/\s+/g, " "); | |
| 87 | expect(html).toContain("a fresh app a-booking-page-for-kai-s-shop"); | |
| 88 | expect(html).toContain("https://kai--forge.test"); | |
| 89 | expect(await readdir(join(dir, "spawn")).catch(() => [])).toEqual([]); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a multi-line sentence is refused beside its field", async () => { | |
| 93 | const f = await post([...base, ["fresh", "one\ntwo"], ["do", "plan"]]); | |
| 94 | expect(f.status).toBe(422); | |
| 95 | expect(await f.text()).toContain("must be one line"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("spawn makes two forks and a fresh app and answers one claim link", async () => { | |
| 99 | const res = await post([ | |
| 100 | ...base, | |
| 101 | ["fresh", "A *booking* page <b>[x](y)"], | |
| 102 | ["do", "spawn"], | |
| 103 | ]); | |
| 104 | expect(res.status).toBe(201); | |
| 105 | expect(res.headers.get("cache-control")).toBe("no-store"); | |
| 106 | expect((await res.text()).match(/\/claim#/g)?.length).toBe(1); | |
| 107 | ||
| 108 | const ledger = await Bun.file(join(dir, "spawn", "kai.json")).json(); | |
| 109 | const child = childDirOf(app, ledger.bodyId); | |
| 110 | const meta = await Bun.file(join(child, "parent.json")).json(); | |
| 111 | expect(meta.apps).toEqual([ | |
| 112 | { repo: "kai/bakery", upstream: "alice/bakery" }, | |
| 113 | { repo: "kai/shop", upstream: "alice/shop" }, | |
| 114 | ]); | |
| 115 | expect(meta.fresh).toBe("kai/a-booking-page-b-x-y"); | |
| 116 | expect("domain" in meta).toBe(false); | |
| 117 | ||
| 118 | const repos = await readdir(join(child, "repos", "kai")).catch(() => [] as string[]); | |
| 119 | expect(repos.some((r) => r.startsWith("a-booking-page-b-x-y"))).toBe(true); | |
| 120 | const index = Bun.spawnSync( | |
| 121 | ["git", "show", "main:index.ts"], | |
| 122 | { cwd: repoDir(join(child, "repos"), "kai", "a-booking-page-b-x-y") }, | |
| 123 | ); | |
| 124 | expect(index.stdout.toString()).toContain("A *booking* page"); | |
| 125 | const readme = Bun.spawnSync(["git", "show", "main:README.md"], { | |
| 126 | cwd: repoDir(join(child, "repos"), "kai", "a-booking-page-b-x-y"), | |
| 127 | }).stdout.toString(); | |
| 128 | // No live markdown or HTML from the sentence: every mark is escaped. | |
| 129 | expect(readme).toContain("A \\*booking\\* page \\<b\\>\\[x\\]\\(y\\)"); | |
| 130 | const forkLog = Bun.spawnSync(["git", "log", "--oneline", "main"], { | |
| 131 | cwd: repoDir(join(child, "repos"), "kai", "bakery"), | |
| 132 | }); | |
| 133 | expect(forkLog.stdout.toString()).toContain("bakery"); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a fresh name avoids a forked app's name; none given changes nothing", () => { | |
| 137 | expect(freshName("Bakery", new Set(["bakery"]))).toBe("bakery-2"); | |
| 138 | expect(freshName("!!!", new Set())).toBe("app"); | |
| 139 | const plan = planSpawn( | |
| 140 | { name: "x", org: "x", apps: [], carryBriefs: false, carrySubscriptions: false }, | |
| 141 | { catalog: [], briefs: [], subscriptions: [] }, | |
| 142 | ); | |
| 143 | expect(plan.ok && "fresh" in plan.value).toBe(false); | |
| 144 | }); | |
| 145 | ||
| 146 | test("a fresh app never takes the name `forge`, which the forge-code fork owns", () => { | |
| 147 | for (const sentence of ["Forge", "forge!"]) { | |
| 148 | const plan = planSpawn( | |
| 149 | { name: "x", org: "x", apps: [], carryBriefs: false, carrySubscriptions: false, fresh: sentence }, | |
| 150 | { catalog: [], briefs: [], subscriptions: [] }, | |
| 151 | ); | |
| 152 | expect(plan.ok && plan.value.fresh?.name).toBe("forge-2"); | |
| 153 | } | |
| 154 | }); | |
| 155 | }); |
test/spawn-keys.test.ts +1
| @@ -357,6 +357,7 @@ describe("a spawned child's own keys and domain", () => { | ||
| 357 | 357 | await Bun.file(join(childDir, "parent.json")).text(), |
| 358 | 358 | ) as Record<string, unknown>; |
| 359 | 359 | expect(Object.keys(record).sort()).toEqual([ |
| 360 | "apps", | |
| 360 | 361 | "keepAccess", |
| 361 | 362 | "name", |
| 362 | 363 | "org", |
test/spawn.test.ts +1
| @@ -79,6 +79,7 @@ describe("spawnForge", () => { | ||
| 79 | 79 | expect(db.query("SELECT brief FROM agent").all()).toEqual([{ brief: "ship small" }]); |
| 80 | 80 | db.close(); |
| 81 | 81 | expect(JSON.parse(await Bun.file(join(child, "parent.json")).text())).toEqual({ |
| 82 | apps: [{ repo: "kids/notes", upstream: "alice/notes" }], | |
| 82 | 83 | upstream: "https://forge.test", |
| 83 | 84 | name: "kid", |
| 84 | 85 | org: "kids", |
This runs before
forkForgeCode, so a fresh repo namedforgeis created first. The fork then overwrites itsmain, because it fetches+refs/heads/*into the existing repo. Once the name can no longer collide (see plan.ts), this order is safe.