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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7642-forge-a-repository-one-action-on-any-rep → main 2 commits fork
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 | ||
| 78 | 78 | |
| 79 | 79 | 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`. |
| 80 | 80 | |
| 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 | ||
| 81 | 83 | 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. |
| 82 | 84 | |
| 83 | 85 | 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 | ||
| 265 | 265 | import { firstAdminOptions, firstAdminPage, firstAdminSubmit } from "../web/pages/first-admin.ts"; |
| 266 | 266 | import { familyPage } from "../web/pages/family.ts"; |
| 267 | 267 | import { forgeLinkSubmit, forgesPage } from "../web/pages/forges.ts"; |
| 268 | import { forgeItSubmit } from "../web/pages/forge-it.ts"; | |
| 268 | 269 | import { newForgePage, newForgeSubmit } from "../web/pages/new-forge.ts"; |
| 269 | 270 | import { askedPage } from "../web/studio/asked_page.ts"; |
| 270 | 271 | import { reviewPage } from "../web/studio/review_page.ts"; |
| @@ -1379,6 +1380,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1379 | 1380 | router.add("GET", "/:owner/:name/commits", commitsPage); |
| 1380 | 1381 | router.add("GET", "/:owner/:name/commit/:oid", commitPage); |
| 1381 | 1382 | 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); | |
| 1382 | 1385 | // Make it yours (#606): a fork with its app, planned, then made. |
| 1383 | 1386 | router.add("GET", "/:owner/:name/make-it-yours", makeItYoursPage); |
| 1384 | 1387 | 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"; | ||
| 128 | 128 | import { reportsCommands } from "./reports.ts"; |
| 129 | 129 | import { tellCommands } from "./tell.ts"; |
| 130 | 130 | import { workingCommands } from "./working.ts"; |
| 131 | import { forgeItCommands } from "./forge_it.ts"; | |
| 131 | 132 | import { makeItYoursScreen, yoursCommands } from "./yours.ts"; |
| 132 | 133 | import { visionCommands } from "./vision.ts"; |
| 133 | 134 | import { appEnvCommands } from "./appenv.ts"; |
| @@ -1438,6 +1439,7 @@ export function workspaceScreen( | ||
| 1438 | 1439 | ...reportsCommands(f, ctx, repo), |
| 1439 | 1440 | ...noteCommands(f, ctx, () => screen, repo), |
| 1440 | 1441 | ...yoursCommands(f, ctx, repo), |
| 1442 | ...forgeItCommands(f, ctx, repo, () => screen), | |
| 1441 | 1443 | ...hooksCommands(f, ctx, repo), |
| 1442 | 1444 | ...visionCommands(f, ctx, repo), |
| 1443 | 1445 | ...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( | ||
| 239 | 239 | function forkBar(ctx: Ctx, repo: Repo): Html { |
| 240 | 240 | const canFork = |
| 241 | 241 | 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"; | |
| 242 | 244 | const forkCount = forksFor(ctx, repo).length; |
| 243 | if (!canFork && forkCount === 0) return html``; | |
| 245 | if (!canFork && !canForge && forkCount === 0) return html``; | |
| 244 | 246 | return html`<div class="mt"> |
| 245 | 247 | ${listCard([ |
| 246 | 248 | row({ |
| @@ -251,11 +253,15 @@ function forkBar(ctx: Ctx, repo: Repo): Html { | ||
| 251 | 253 | >${badge(`${forkCount} fork${forkCount === 1 ? "" : "s"}`)}</a |
| 252 | 254 | >` |
| 253 | 255 | : "Forks", |
| 254 | trail: canFork | |
| 256 | trail: html`${canFork | |
| 255 | 257 | ? html`<form method="post" action="${repoBase(repo)}/fork"> |
| 256 | 258 | ${btn("Fork", { size: "sm", icon: "fork" })} |
| 257 | 259 | </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``}`, | |
| 259 | 265 | }), |
| 260 | 266 | ])} |
| 261 | 267 | </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 | }); |
new above
offline