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

Forge a repository: one action on any repository spins up a fresh forge with it as the template #7834

Merged opened this in answer to

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

Merged

scout/forge:scout/7642-forge-a-repository-one-action-on-any-rep → main 2 commits fork

  • Merged. scout/7642-forge-a-repository-one-action-on-any-rep was merged into main as 8e0bf2e.

Review

Sign in to review this change.
10 files changed +428 −3
README.md +2
@@ -78,6 +78,8 @@ In the terminal, the Capacity screen (palette: Capacity, a forge admin who is a
7878
7979 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`.
8080
81 **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`.
82
8183 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.
8284
8385 On a sealed issue, a writer gets the private repository to fix it in: `POST /api/v1/repos/:owner/:name/issues/:number/private`, the issue page's button, and `p` on the issue's terminal screen, which shows the repository and its clone URL (built from the forge's origin). All three refuse alike: not found for anyone who may not see sealed reports, `not_sealed`, and `unverified`.
docs/features/forge-it.md added +14
@@ -0,0 +1,14 @@
1 # Forge it
2
3 A **Forge it** button (rocket icon, outline weight, so it is not mistaken for Fork) sits beside Fork on every repository page, for any signed-in person. One press makes a fresh forge named for the repository (`Bakery_Shop` becomes `bakery-shop`) with that repository as its app, and sends the browser straight to the new forge's claim link. The claim code rides the URL fragment, so there is nothing to copy; choosing a username and password there signs the person in as the new forge's admin.
4
5 - Terminal: the repository screen's palette (`:`) has `Forge it`; a confirm, then the claim link once (`src/tui/screens/forge_it.ts`). Both doors share `src/spawn/forge_it.ts`.
6 - Route: `POST /:owner/:name/forge` (`src/web/pages/forge-it.ts`), same-origin and `admin`-scoped like `POST /forges/new`, which it calls through the spawn door with the defaults.
7 - A private repository is forged only by those who can read it. Limits (children per forge, rate) are the spawn door's.
8 - Pressing it twice makes one forge: the second press goes to `/forges`, and the link is not shown again (ask for a new one there).
9 - Agents: the same thing is MCP `spawn` / `POST /api/v1/forges` with `apps: ["owner/name"]`.
10 - Tests: `test/tui-forge-it.test.ts` for the terminal; `test/forge-it.test.ts` walks press, claim, signed-in admin, files.
11
12 Not here: a hosted child's crew runner (#4416), arriving signed in with no claim form (#2928), choosing a template (#7534, #7535).
13
14 Direction: this is a feature ahead of the pipeline-first order only in being a small door onto the spawn path that already exists; it adds no pipeline code. The owner asked for it on #7642.
src/http/server.ts +3
@@ -265,6 +265,7 @@ import { claimFragmentPage, claimOptions, claimSubmit } from "../web/pages/claim
265265 import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts";
266266 import { familyPage } from "../web/pages/family.ts";
267267 import { forgeLinkSubmit, forgesPage } from "../web/pages/forges.ts";
268 import { forgeItSubmit } from "../web/pages/forge-it.ts";
268269 import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts";
269270 import { askedPage } from "../web/studio/asked_page.ts";
270271 import { reviewPage } from "../web/studio/review_page.ts";
@@ -1379,6 +1380,8 @@ export function buildRouter(): Router<Handler> {
13791380 router.add("GET", "/:owner/:name/commits", commitsPage);
13801381 router.add("GET", "/:owner/:name/commit/:oid", commitPage);
13811382 router.add("POST", "/:owner/:name/fork", forkSubmit);
1383 // Forge it: a fresh forge with this repository as its app, then its claim link.
1384 router.add("POST", "/:owner/:name/forge", forgeItSubmit);
13821385 // Make it yours (#606): a fork with its app, planned, then made.
13831386 router.add("GET", "/:owner/:name/make-it-yours", makeItYoursPage);
13841387 router.add("POST", "/:owner/:name/make-it-yours", makeItYoursSubmit);
src/spawn/forge_it.ts added +30
@@ -0,0 +1,30 @@
1 /**
2 * Forge it: one repository becomes the app of a fresh forge. The page's
3 * button and the terminal's palette entry both come here, so the name and
4 * the spawn are the same in both. It is the spawn door with the choices made.
5 */
6
7 import type { Repo } from "../core/repos.ts";
8 import type { User } from "../core/users.ts";
9 import type { Ctx } from "../http/context.ts";
10 import { spawnDoor, type Door } from "./door.ts";
11 import { CHILD_NAME } from "./placement.ts";
12
13 /** A forge name from a repository name: lowercase, dashes, at most 38 characters. */
14 export function forgeNameOf(repoName: string): string {
15 const slug = repoName
16 .toLowerCase()
17 .replace(/[^a-z0-9]+/g, "-")
18 .replace(/^-+|-+$/g, "")
19 .slice(0, 38)
20 .replace(/-+$/, "");
21 return CHILD_NAME.test(slug) ? slug : "forge";
22 }
23
24 /** Make the forge (the door refuses what a person may not do). */
25 export const forgeIt = (ctx: Ctx, user: User, repo: Repo): Promise<Door> =>
26 spawnDoor(ctx, user, {
27 name: forgeNameOf(repo.name),
28 apps: [`${repo.ownerName}/${repo.name}`],
29 dryRun: false,
30 });
src/tui/screens/forge_it.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * Forge it, from a repository screen's `:` palette: the terminal's door to
3 * the Forge button beside Fork. A confirm, then the spawn door as the page
4 * calls it, and the claim link shown once.
5 */
6
7 import type { Repo } from "../../core/repos.ts";
8 import { forgeIt, forgeNameOf } from "../../spawn/forge_it.ts";
9 import type { Ctx, Screen } from "../app/app.ts";
10 import type { Command } from "../app/palette.ts";
11 import type { Session } from "../session.ts";
12 import { confirmScreen, linkScreen, runAsync } from "./common.ts";
13
14 export function forgeItScreen(f: Session, ctx: Ctx, repo: Repo, parent: Screen): Screen {
15 const from = `${repo.ownerName}/${repo.name}`;
16 return confirmScreen(ctx, parent, {
17 title: "Forge it",
18 body: [
19 `A fresh forge, ${forgeNameOf(repo.name)}, with ${from} as its app.`,
20 "You get its claim link once; whoever opens it becomes the admin.",
21 ],
22 actions: [{ label: "Forge it" }, { label: "Cancel" }],
23 onChoose: (choice) => {
24 if (choice !== 0 || f.user === undefined) return;
25 const user = f.user;
26 runAsync(ctx, async () => {
27 const url = new URL(f.app.config.url ?? `http://${f.app.config.host ?? "localhost"}`);
28 const made = await forgeIt(
29 {
30 app: f.app,
31 request: new Request(url.href, { method: "POST" }),
32 url,
33 params: {},
34 auth: { user, scope: undefined, via: "none" as const },
35 theme: undefined,
36 },
37 user,
38 repo,
39 );
40 if (made.status >= 400) return ctx.toast(String(made.body["message"] ?? "it could not be made"), "danger");
41 const link = made.body["claimUrl"];
42 if (typeof link !== "string")
43 return ctx.toast(`${String(made.body["name"])} was forged before; its link is not shown again`, "attn");
44 ctx.toast(`${String(made.body["name"])} is ready`, "success");
45 ctx.replace(linkScreen(ctx, link));
46 });
47 },
48 });
49 }
50
51 export function forgeItCommands(f: Session, ctx: Ctx, repo: Repo | undefined, parent: () => Screen): Command[] {
52 if (repo === undefined || f.user === undefined || f.user.kind !== "user") return [];
53 return [
54 {
55 id: "forge-it",
56 title: `${repo.ownerName}/${repo.name} — Forge it`,
57 hint: "a fresh forge with it as the app",
58 run: () => ctx.push(forgeItScreen(f, ctx, repo, parent())),
59 },
60 ];
61 }
src/tui/screens/workspace.ts +2
@@ -128,6 +128,7 @@ import { reportCommands } from "./report.ts";
128128 import { reportsCommands } from "./reports.ts";
129129 import { tellCommands } from "./tell.ts";
130130 import { workingCommands } from "./working.ts";
131 import { forgeItCommands } from "./forge_it.ts";
131132 import { makeItYoursScreen, yoursCommands } from "./yours.ts";
132133 import { visionCommands } from "./vision.ts";
133134 import { appEnvCommands } from "./appenv.ts";
@@ -1438,6 +1439,7 @@ export function workspaceScreen(
14381439 ...reportsCommands(f, ctx, repo),
14391440 ...noteCommands(f, ctx, () => screen, repo),
14401441 ...yoursCommands(f, ctx, repo),
1442 ...forgeItCommands(f, ctx, repo, () => screen),
14411443 ...hooksCommands(f, ctx, repo),
14421444 ...visionCommands(f, ctx, repo),
14431445 ...appEnvCommands(f, ctx, repo),
src/web/pages/forge-it.ts added +33
@@ -0,0 +1,33 @@
1 /**
2 * Forge it: the verb beside Fork. One press on a repository spins up a fresh
3 * forge with that repository as its app, and sends the person straight to the
4 * new forge's claim link (the code rides the fragment, so nothing is copied).
5 * It is `POST /forges/new` with the choices made for them: the name comes from
6 * the repository, everything else is the spawn door's default.
7 */
8
9 import type { Ctx } from "../../http/context.ts";
10 import { forgeIt } from "../../spawn/forge_it.ts";
11 import { loadRepo, notFoundPage, signInFirst } from "../chrome.ts";
12 import { readForm } from "../form.ts";
13 import { flashTo, redirect } from "../layout.ts";
14
15 // POST /:owner/:name/forge
16 export async function forgeItSubmit(ctx: Ctx): Promise<Response> {
17 const repo = loadRepo(ctx);
18 if (!repo) return notFoundPage(ctx);
19 const user = ctx.auth.user;
20 if (!user) return signInFirst(ctx);
21 const form = await readForm(ctx, "admin");
22 if (form instanceof Response) return form;
23 const back = `/${repo.ownerName}/${repo.name}`;
24 const door = await forgeIt(ctx, user, repo);
25 if (door.status >= 400)
26 return flashTo(ctx, back, { tone: "danger", message: `Not forged: ${String(door.body["message"] ?? "it could not be made")}.` });
27 const link = door.body["claimUrl"];
28 if (typeof link !== "string")
29 return flashTo(ctx, "/forges", { message: `${String(door.body["name"])} was forged before; its link is not shown again.` });
30 const response = redirect(link);
31 response.headers.set("cache-control", "no-store");
32 return response;
33 }
src/web/pages/repo.ts +9 −3
@@ -239,8 +239,10 @@ function forkRanking(
239239 function forkBar(ctx: Ctx, repo: Repo): Html {
240240 const canFork =
241241 ctx.auth.user !== undefined && ctx.auth.user.id !== repo.ownerId;
242 // Forge it is for any signed-in person, the owner too: it makes a forge, not a copy in this one.
243 const canForge = ctx.auth.user?.kind === "user";
242244 const forkCount = forksFor(ctx, repo).length;
243 if (!canFork && forkCount === 0) return html``;
245 if (!canFork && !canForge && forkCount === 0) return html``;
244246 return html`<div class="mt">
245247 ${listCard([
246248 row({
@@ -251,11 +253,15 @@ function forkBar(ctx: Ctx, repo: Repo): Html {
251253 >${badge(`${forkCount} fork${forkCount === 1 ? "" : "s"}`)}</a
252254 >`
253255 : "Forks",
254 trail: canFork
256 trail: html`${canFork
255257 ? html`<form method="post" action="${repoBase(repo)}/fork">
256258 ${btn("Fork", { size: "sm", icon: "fork" })}
257259 </form>`
258 : html``,
260 : html``}${canForge
261 ? html`<form method="post" action="${repoBase(repo)}/forge">
262 ${btn("Forge it", { size: "sm", icon: "rocket", variant: "outline" })}
263 </form>`
264 : html``}`,
259265 }),
260266 ])}
261267 </div>`;
test/forge-it.test.ts added +186
@@ -0,0 +1,186 @@
1 /**
2 * Forge it (#7642): one press beside Fork on a repository makes a fresh forge
3 * with that repository as its app and lands the person on its claim link; the
4 * claim signs them in as the new forge's admin, holding the repository's files.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, 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 { getRepo } from "../src/core/repos.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
15 import { repoDir } from "../src/git/repos.ts";
16 import { commitFiles } from "../src/git/write.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { forgeNameOf } from "../src/spawn/forge_it.ts";
19 import { useProbe } from "../src/spawn/door.ts";
20 import { firstBoot } from "../src/spawn/first-boot.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24
25 const PASSWORD = "a-long-claim-passphrase";
26
27 describe("forge it", () => {
28 let dir = "";
29 let parent: App;
30 let parentServers: Servers;
31 let cookie = "";
32 let bobCookie = "";
33 let link = "";
34
35 const post = (path: string, extra: Record<string, string> = {}) =>
36 fetch(`${parentServers.url}${path}`, {
37 method: "POST",
38 headers: { origin: "https://forge.test", ...extra },
39 redirect: "manual",
40 });
41
42 beforeAll(async () => {
43 dir = await mkdtemp(join(tmpdir(), "forge-it-"));
44 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
45 const alice = await createUser(parent.db, {
46 origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: PASSWORD, isAdmin: true,
47 });
48 if (!alice.ok) throw new Error("user");
49 const repo = await createRepo(parent, alice.value, { name: "Bakery_Shop" });
50 if (!repo.ok) throw new Error("repo");
51 const c = await commitFiles(repoDir(parent.reposRoot, "alice", "Bakery_Shop"), {
52 branch: "main", message: "bakery",
53 files: [{ path: "README.md", content: "# bakery\n" }],
54 author: { name: "a", email: "[email protected]" },
55 });
56 if (!c.ok) throw new Error("commit");
57 useProbe(parent, () => async () => true);
58 parentServers = await serve(parent);
59 const bob = await createUser(parent.db, {
60 origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: PASSWORD,
61 });
62 if (!bob.ok) throw new Error("bob");
63 const open = await createRepo(parent, alice.value, { name: "open", visibility: "public" });
64 if (!open.ok) throw new Error("open");
65 const oc = await commitFiles(repoDir(parent.reposRoot, "alice", "open"), {
66 branch: "main", message: "open",
67 files: [{ path: "README.md", content: "# open\n" }],
68 author: { name: "a", email: "[email protected]" },
69 });
70 if (!oc.ok) throw new Error("commit");
71 const signIn = async (username: string) => {
72 const res = await fetch(`${parentServers.url}/signin`, {
73 method: "POST",
74 headers: { origin: "https://forge.test" },
75 body: new URLSearchParams({ username, password: PASSWORD }),
76 redirect: "manual",
77 });
78 const c = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
79 if (!c.includes("=")) throw new Error(`signin ${res.status}`);
80 return c;
81 };
82 cookie = await signIn("alice");
83 bobCookie = await signIn("bob");
84 }, 60_000);
85
86 afterAll(async () => {
87 parentServers.stop();
88 parent.close();
89 await rm(dir, { recursive: true, force: true });
90 });
91
92 test("the repository page offers Forge beside Fork, to a signed-in person only", async () => {
93 const signedIn = await (await fetch(`${parentServers.url}/alice/Bakery_Shop`, { headers: { cookie } })).text();
94 expect(signedIn).toContain('action="/alice/Bakery_Shop/forge"');
95 const stranger = await (await fetch(`${parentServers.url}/alice/Bakery_Shop`)).text();
96 expect(stranger).not.toContain("/forge\"");
97 });
98
99 test("a name comes from the repository and always fits", () => {
100 expect(forgeNameOf("Bakery_Shop")).toBe("bakery-shop");
101 expect(forgeNameOf("___")).toBe("forge");
102 expect(forgeNameOf("a".repeat(60))).toHaveLength(38);
103 });
104
105 test("a stranger and a cross-origin press make nothing", async () => {
106 // A private repository is not there to a stranger.
107 expect((await post("/alice/Bakery_Shop/forge")).status).toBe(404);
108 expect((await post("/alice/Bakery_Shop/forge", { cookie, origin: "https://evil.test" })).status).toBe(403);
109 expect(await Bun.file(join(dir, "spawn", "bakery-shop.json")).exists()).toBe(false);
110 });
111
112 test("pressing it lands on the claim link, which signs in an admin holding the files", async () => {
113 const res = await post("/alice/Bakery_Shop/forge", { cookie });
114 expect(res.status).toBe(303);
115 expect(res.headers.get("cache-control")).toBe("no-store");
116 link = res.headers.get("location")!;
117 expect(link).toMatch(/^https:\/\/bakery-shop--forge\.test\/claim#[A-Za-z0-9_-]{43}$/);
118
119 const ledger = await Bun.file(join(dir, "spawn", "bakery-shop.json")).json();
120 const child = createApp({ dataDir: childDirOf(parent, ledger.bodyId), port: 0 });
121 await firstBoot(child);
122 const childServers = await serve(child);
123 try {
124 const claimed = await fetch(`${childServers.url}/claim`, {
125 method: "POST",
126 headers: { origin: childServers.url },
127 body: new URLSearchParams({ token: link.split("#")[1]!, username: "maria", email: "[email protected]", password: PASSWORD }),
128 redirect: "manual",
129 });
130 expect(claimed.status).toBe(303);
131 const session = (claimed.headers.get("set-cookie") ?? "").split(";")[0]!;
132 expect(session).toContain("=");
133 expect(getUserByName(child.db, "maria")?.isAdmin).toBe(true);
134 const repo = getRepo(child.db, "bakery-shop", "Bakery_Shop");
135 expect(repo).toBeDefined();
136 const home = await fetch(`${childServers.url}/`, { headers: { cookie: session } });
137 expect(home.status).toBe(200);
138 expect(await home.text()).toContain("maria");
139 } finally {
140 childServers.stop();
141 child.close();
142 }
143 }, 30_000);
144
145 test("pressing it again makes no second forge and shows no second link", async () => {
146 const again = await post("/alice/Bakery_Shop/forge", { cookie });
147 expect(again.status).toBe(303);
148 expect(again.headers.get("location")).toBe("/forges");
149 });
150
151 test("signed in but unable to read: a private repository is a 404 and makes no ledger entry", async () => {
152 const res = await post("/alice/Bakery_Shop/forge", { cookie: bobCookie });
153 expect(res.status).toBe(404);
154 const page = await fetch(`${parentServers.url}/alice/Bakery_Shop`, { headers: { cookie: bobCookie } });
155 expect(await page.text()).not.toContain("/forge\"");
156 expect(await Bun.file(join(dir, "spawn", "bakery-shop.json")).exists()).toBe(true); // alice's, from above
157 expect(await Bun.file(join(dir, "spawn", "bob.json")).exists()).toBe(false);
158 });
159
160 test("signed out on a public repository is sent to sign in and makes nothing", async () => {
161 const res = await post("/alice/open/forge");
162 expect(res.status).toBe(303);
163 expect(res.headers.get("location") ?? "").toContain("/signin");
164 expect(await Bun.file(join(dir, "spawn", "open.json")).exists()).toBe(false);
165 });
166
167 test("a reader who is not the owner may forge a public repository", async () => {
168 const res = await post("/alice/open/forge", { cookie: bobCookie });
169 expect(res.status).toBe(303);
170 expect(res.headers.get("location")).toMatch(/^https:\/\/open--forge\.test\/claim#/);
171 });
172
173 test("a refusal comes back as an escaped flash on the repository page", async () => {
174 // `open_` is named `open` as well, with other apps: the door refuses with 409.
175 const other = await createRepo(parent, (await getUserByName(parent.db, "alice"))!, { name: "open_", visibility: "public" });
176 if (!other.ok) throw new Error("open_");
177 const res = await post("/alice/open_/forge", { cookie });
178 expect(res.status).toBe(303);
179 expect(res.headers.get("location")).toBe("/alice/open_");
180 const flash = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
181 expect(flash).toStartWith("forge_flash=");
182 const shown = await (await fetch(`${parentServers.url}/alice/open_`, { headers: { cookie: `${cookie}; ${flash}` } })).text();
183 expect(shown).toContain("Not forged:");
184 expect(shown).not.toMatch(/Not forged:[^<]*<script/i);
185 });
186 });
test/tui-forge-it.test.ts added +88
@@ -0,0 +1,88 @@
1 /** The terminal's repository screen offers Forge it, as the page's button does (#7642). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import type { Repo } from "../src/core/repos.ts";
9 import { createUser, type User } from "../src/core/users.ts";
10 import { commitFiles } from "../src/git/write.ts";
11 import { repoDir } from "../src/git/repos.ts";
12 import { useProbe } from "../src/spawn/door.ts";
13 import { createForgeApp } from "../src/tui/forge.ts";
14 import { repoScreen } from "../src/tui/screens/repo.ts";
15 import { local } from "../src/tui/screens/where.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 let dir: string;
23 let forge: App;
24 let alice: User;
25 let repo: Repo;
26
27 beforeAll(async () => {
28 dir = await mkdtemp(join(tmpdir(), "forge-tui-forge-it-"));
29 forge = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
30 const a = await createUser(forge.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-long-passphrase", isAdmin: true });
31 if (!a.ok) throw new Error("user");
32 alice = a.value;
33 const made = await createRepo(forge, alice, { name: "bakery", visibility: "public" });
34 if (!made.ok) throw new Error("repo");
35 repo = made.value;
36 const c = await commitFiles(repoDir(forge.reposRoot, "alice", "bakery"), {
37 branch: "main", message: "bakery", files: [{ path: "README.md", content: "# bakery\n" }], author: { name: "a", email: "[email protected]" },
38 });
39 if (!c.ok) throw new Error("commit");
40 useProbe(forge, () => async () => true);
41 }, 60_000);
42 afterAll(async () => {
43 forge.close?.();
44 await rm(dir, { recursive: true, force: true });
45 });
46
47 const open = async (user: User | undefined) => {
48 const { app, session } = createForgeApp(
49 forge,
50 { sshUsername: user?.username ?? "anon", remoteAddress: "127.0.0.1", user },
51 { terminal: headlessTerminal(120, 40), exitHooks: false },
52 );
53 app.start();
54 await settleApp(app);
55 app.push(repoScreen(session, app.ctx, local(session, repo)));
56 await settleApp(app);
57 return app;
58 };
59 const key = async (app: Awaited<ReturnType<typeof open>>, k: string): Promise<string> => {
60 app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
61 await settleApp(app);
62 return app.screen.toText();
63 };
64 const type = async (app: Awaited<ReturnType<typeof open>>, text: string): Promise<string> => {
65 let out = "";
66 for (const ch of text) out = await key(app, ch);
67 return out;
68 };
69
70 test("a signed-in person is offered Forge it in the palette; a stranger is not", async () => {
71 const app = await open(alice);
72 await key(app, ":");
73 expect(await type(app, "forge it")).toContain("alice/bakery — Forge it");
74 const stranger = await open(undefined);
75 await key(stranger, ":");
76 expect(await type(stranger, "forge it")).not.toContain("alice/bakery — Forge it");
77 });
78
79 test("Forge it confirms, then makes the forge and shows its claim link once", async () => {
80 const app = await open(alice);
81 await key(app, ":");
82 await type(app, "forge it");
83 const confirm = await key(app, "enter");
84 expect(confirm).toContain("bakery");
85 expect(await Bun.file(join(dir, "spawn", "bakery.json")).exists()).toBe(false);
86 expect(await key(app, "enter")).toMatch(/bakery--forge\.test\/claim#/);
87 expect(await Bun.file(join(dir, "spawn", "bakery.json")).exists()).toBe(true);
88 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs