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

Merged opened this

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

Merged

scout/forge:scout/1608-forges-new-gives-a-client-s-forge-the-ow → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/1608-forges-new-gives-a-client-s-forge-the-ow was merged into main as 8d7973c.

Review

Sign in to review this change.
12 files changed +309 −1
README.md +2
@@ -151,6 +151,8 @@ Unset, only this machine may say so, and the named header is never read. Everyth
151151 instance _is_ lives under the data directory — the SQLite index, the bare
152152 repos, and the SSH host key, in plain layout. Tar it up and it moves.
153153
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
154156 `FORGE_PARENT_URL` names the forge this one was spawned from. It must be
155157 `https://` with no credentials or query (the forge refuses to boot otherwise).
156158 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
4848
4949 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.
5050
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
5155 ## Choices
5256
5357 - Following across forges is HTTPS git with scoped tokens, because hosted
scripts/gate.ts +1
@@ -197,6 +197,7 @@ export const GATE: readonly string[] = [
197197 "test/spawn-api.test.ts",
198198 "test/spawn-child-apps.test.ts",
199199 "test/spawn-first-boot.test.ts",
200 "test/spawn-fresh-forks.test.ts",
200201 "test/spawn-keys.test.ts",
201202 "test/web-apps.test.ts",
202203 "test/web-ui.test.ts",
src/actions/spawn.ts +69
@@ -35,6 +35,7 @@ import {
3535 import { createUser, getUserByName, type User } from "../core/users.ts";
3636 import { runGit } from "../git/env.ts";
3737 import { repoDir } from "../git/repos.ts";
38 import { commitFiles } from "../git/write.ts";
3839 import { sha256 } from "../kernel/hash.ts";
3940 import { secret } from "../kernel/id.ts";
4041 import { err, ok, type Result } from "../kernel/result.ts";
@@ -344,6 +345,13 @@ async function copyInto(
344345 copiedRepos.push({ parent, child: repo });
345346 }
346347
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);

This runs before forkForgeCode, so a fresh repo named forge is created first. The fork then overwrites its main, because it fetches +refs/heads/* into the existing repo. Once the name can no longer collide (see plan.ts), this order is safe.

352 if (!made.ok) return err(made.error);
353 }
354
347355 // The forge's own code, forked into the child with its full history,
348356 // so every child starts from the forge and can follow its parent.
349357 // A cross-forge fork: the history is copied by git, and the upstream
@@ -419,6 +427,9 @@ async function copyInto(
419427 // The child's own address: <name>--<parent's host>.
420428 url: liveChildUrl(app, plan.name),
421429 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}` }),
422433 // The forge's code, forked into the child: where it came from, so
423434 // the child can follow its parent's updates.
424435 ...(forgeCode.value === undefined
@@ -434,6 +445,64 @@ async function copyInto(
434445 }
435446 }
436447
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
437506 /**
438507 * The parent's own code, forked into the child's organisation with its full
439508 * 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[] = [
966966 description:
967967 "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.",
968968 },
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 },
969974 keepAccess: {
970975 type: "boolean",
971976 description:
src/spawn/door.ts +1
@@ -232,6 +232,7 @@ export async function spawnDoor(
232232 // by whoever opens the link.
233233 keepAccess: anon ? false : raw["keepAccess"],
234234 budgetUsd: raw["budgetUsd"],
235 fresh: raw["fresh"],
235236 ...(anon ? {} : { for: raw["for"] }),
236237 };
237238
src/spawn/plan.ts +44
@@ -29,6 +29,8 @@ export const KEY_BUDGET_NOTE =
2929 "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.";
3030 export const MAX_BUDGET_USD = 1000;
3131
32 /** One line saying what a fresh app is. */
33 export const MAX_FRESH_CHARS = 200;
3234 const NAME = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
3335 const APP_REF = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
3436
@@ -62,6 +64,8 @@ export interface SpawnInput {
6264 * The key itself never enters the plan: only that there is one, capped.
6365 */
6466 readonly budgetUsd?: unknown;
67 /** One line describing a fresh app to start in the child, beside the forked ones; optional. */
68 readonly fresh?: unknown;
6569 }
6670
6771 /** What the parent has to offer. Extra fields are ignored, never copied. */
@@ -78,6 +82,12 @@ export interface PlannedApp {
7882 readonly bytes: number;
7983 }
8084
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
8191 export interface SpawnPlan {
8292 readonly name: string;
8393 readonly org: string;
@@ -92,6 +102,8 @@ export interface SpawnPlan {
92102 readonly budgetUsd: number | null;
93103 /** Whom the child is for, for the parent's client list; absent when unsaid. */
94104 readonly forWhom?: string;
105 /** A fresh app, present only when asked for. */
106 readonly fresh?: PlannedFresh;
95107 }
96108
97109 export interface DryRun {
@@ -116,6 +128,20 @@ export const LEFT_BEHIND: readonly string[] = [
116128 const refuse = (field: string, reason: string) => err<Refusal>({ field, reason });
117129 const byText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
118130
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é" → caf) or falls back to app. That is safe, but a client may not recognise the name. Normalising first with .normalize("NFKD").replace(/\p{M}/gu, "") would keep cafe.

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
119145 export function planSpawn(
120146 input: SpawnInput,
121147 source: SpawnSource,
@@ -197,6 +223,20 @@ export function planSpawn(
197223 if (forWhom.length > MAX_FOR_CHARS) return refuse("for", `at most ${MAX_FOR_CHARS} characters`);
198224 }
199225
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 forge, the fixed name forkForgeCode gives the forge's code fork. A sentence that slugs to forge makes a private fresh repo <org>/forge. forkForgeCode then reuses it and force-fetches the forge's branches over it. Add "forge" to this set and test fresh: "Forge" → forge-2.

235 name: freshName(description, new Set(["forge", ...apps.map((a) => a.ref.split("/")[1]!.toLowerCase())])),
236 description,
237 };
238 }
239
200240 // `forWhom` only when said, so a plan without it hashes as it always did.
201241 return ok({
202242 name,
@@ -208,6 +248,7 @@ export function planSpawn(
208248 keepAccess,
209249 budgetUsd,
210250 ...(forWhom === "" ? {} : { forWhom }),
251 ...(fresh === undefined ? {} : { fresh }),
211252 });
212253 }
213254
@@ -217,6 +258,9 @@ const enc = new TextEncoder();
217258 export function dryRun(plan: SpawnPlan, source: SpawnSource): DryRun {
218259 const copied = [
219260 ...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}`]),
220264 ...plan.briefs.map((b) => `crew brief for ${b.agent}`),
221265 ...plan.subscriptions.map((s) => `subscription ${s} (name only)`),
222266 ...(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
1717 import {
1818 LEFT_BEHIND,
1919 MAX_FOR_CHARS,
20 MAX_FRESH_CHARS,
2021 MAX_BUDGET_USD,
2122 MIN_BUDGET_USD,
2223 } from "../../spawn/plan.ts";
@@ -32,6 +33,7 @@ interface Values {
3233 readonly direction: string;
3334 readonly apps: readonly string[];
3435 readonly crew: readonly string[];
36 readonly fresh: string;
3537 readonly keepAccess: boolean;
3638 readonly budgetUsd: string;
3739 readonly modelEndpoint: string;
@@ -45,6 +47,7 @@ const EMPTY: Values = {
4547 direction: "",
4648 apps: [],
4749 crew: [],
50 fresh: "",
4851 keepAccess: false,
4952 budgetUsd: "",
5053 modelEndpoint: "",
@@ -153,7 +156,7 @@ function formView(
153156 ...(errors["direction"] ? { error: errors["direction"] } : {}),
154157 })}
155158 <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>
157160 ${checks(
158161 "apps",
159162 repos.map((r) => ({
@@ -166,6 +169,13 @@ function formView(
166169 ? html`<span class="error" id="f-apps-err">${errors["apps"]}</span>`
167170 : null}
168171 </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 })}
169179 ${crew.length === 0
170180 ? null
171181 : html`<fieldset class="field">
@@ -349,6 +359,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
349359 direction: field(form, "direction"),
350360 apps: all(form, "apps"),
351361 crew: all(form, "crew"),
362 fresh: field(form, "fresh").trim(),
352363 keepAccess: asBool(form, "keepAccess"),
353364 budgetUsd: field(form, "budgetUsd").trim(),
354365 modelEndpoint: field(form, "modelEndpoint").trim(),
@@ -361,6 +372,7 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
361372 apps: values.apps,
362373 crew: values.crew,
363374 keepAccess: values.keepAccess,
375 ...(values.fresh === "" ? {} : { fresh: values.fresh }),
364376 ...(values.forWhom === "" ? {} : { for: values.forWhom }),
365377 ...(values.direction.trim() === "" ? {} : { direction: values.direction }),
366378 ...(key === "" ? {} : { modelKey: key }),
test/spawn-api.test.ts +13
@@ -119,6 +119,19 @@ describe("spawn over the API and MCP", () => {
119119 expect(placed).toBe(0);
120120 });
121121
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
122135 test("dryRun: false makes the child through the placement, and the ledger lists it", async () => {
123136 const res = await post(rw, { name: "kid", apps: ["alice/notes"], crew: ["scout"], dryRun: false });
124137 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", () => {
357357 await Bun.file(join(childDir, "parent.json")).text(),
358358 ) as Record<string, unknown>;
359359 expect(Object.keys(record).sort()).toEqual([
360 "apps",
360361 "keepAccess",
361362 "name",
362363 "org",
test/spawn.test.ts +1
@@ -79,6 +79,7 @@ describe("spawnForge", () => {
7979 expect(db.query("SELECT brief FROM agent").all()).toEqual([{ brief: "ship small" }]);
8080 db.close();
8181 expect(JSON.parse(await Bun.file(join(child, "parent.json")).text())).toEqual({
82 apps: [{ repo: "kids/notes", upstream: "alice/notes" }],
8283 upstream: "https://forge.test",
8384 name: "kid",
8485 org: "kids",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs