Public: anyone can read this; the crew can change it.
wire pin demos to a route, page, MCP tool and TUI screen, or remove src/actions/pin_demos.ts #9314
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9313-wire-pin-demos-to-a-route-page-mcp-tool → main 1 commit fork
2 files
changed +0
−345
src/actions/pin_demos.ts deleted −253
| @@ -1,253 +0,0 @@ | ||
| 1 | /** | |
| 2 | * A pin's demo: a writer names it, says how it opens, puts it on display | |
| 3 | * pages (the showcase) or takes it off, and gives it a cover. The facts | |
| 4 | * live on the pin's tag (`git/builds.ts`). A demo that opens as a file | |
| 5 | * (`launch` `file`) is the captured HTML page in the data directory | |
| 6 | * (`arcade/carts.ts`); one with no capture yet gets one started, in the | |
| 7 | * background, as the person who asked — the same person a build preview | |
| 8 | * would need. | |
| 9 | * | |
| 10 | * The showcase puts a demo on display pages (`display/items.ts`), for | |
| 11 | * whoever may read its repository. Only a person sets it, never an agent | |
| 12 | * (docs/APPS.md, rule 1). | |
| 13 | */ | |
| 14 | ||
| 15 | import type { App } from "../app.ts"; | |
| 16 | import { captureState, startCapture, type CaptureOptions, type CaptureState } from "../arcade/carts.ts"; | |
| 17 | import { readFileAt } from "../apps/checkout.ts"; | |
| 18 | import { parseManifest } from "../apps/manifest.ts"; | |
| 19 | import type { Repo } from "../core/repos.ts"; | |
| 20 | import type { User } from "../core/users.ts"; | |
| 21 | import { exactRefOid } from "../git/branches.ts"; | |
| 22 | import { | |
| 23 | isDemo, | |
| 24 | launchOf, | |
| 25 | readBuildPin, | |
| 26 | setPinDemo, | |
| 27 | type BuildPin, | |
| 28 | type Demo, | |
| 29 | type Launch, | |
| 30 | type SetDemoError, | |
| 31 | } from "../git/builds.ts"; | |
| 32 | import { isSafeOid } from "../git/guard.ts"; | |
| 33 | import { readerFor } from "../git/read.ts"; | |
| 34 | import { repoDir } from "../git/repos.ts"; | |
| 35 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 36 | import { isMirror } from "../origins/write-through.ts"; | |
| 37 | import { requireWriter, type AccessError } from "./access.ts"; | |
| 38 | ||
| 39 | export type PinDemoError = AccessError | SetDemoError | "mirrored" | "invalid_cover" | "not_a_file"; | |
| 40 | ||
| 41 | export interface DemoInput { | |
| 42 | readonly name: string; | |
| 43 | readonly version: string; | |
| 44 | /** Unset follows the repository's `app.launch`. */ | |
| 45 | readonly launch?: Launch | undefined; | |
| 46 | readonly page: string; | |
| 47 | readonly showcase: boolean; | |
| 48 | } | |
| 49 | ||
| 50 | /** What a commit's `forge.json` says about its demos. */ | |
| 51 | export interface DemoFacts { | |
| 52 | /** `app.pages`: the pages it offers to play and download. */ | |
| 53 | readonly pages: readonly string[]; | |
| 54 | /** `app.launch`, when it names one. */ | |
| 55 | readonly launch?: Launch | undefined; | |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * The pages and the launch a commit's `forge.json` names — or, for a | |
| 60 | * commit pinned before the repository named any pages, what its default | |
| 61 | * branch names now. A page the old build does not serve is simply not | |
| 62 | * found when played. | |
| 63 | */ | |
| 64 | export async function demoFactsAt(app: App, repo: Repo, oid: string): Promise<DemoFacts> { | |
| 65 | if (!isSafeOid(oid)) return { pages: [] }; | |
| 66 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 67 | const read = async (at: string): Promise<DemoFacts> => { | |
| 68 | const [forgeJson, packageJson] = await Promise.all([ | |
| 69 | readFileAt(dir, at, "forge.json"), | |
| 70 | readFileAt(dir, at, "package.json"), | |
| 71 | ]); | |
| 72 | const manifest = parseManifest(forgeJson, packageJson); | |
| 73 | if (!manifest.ok) return { pages: [] }; | |
| 74 | return { | |
| 75 | pages: manifest.value.pages ?? [], | |
| 76 | ...(manifest.value.launch === undefined ? {} : { launch: manifest.value.launch }), | |
| 77 | }; | |
| 78 | }; | |
| 79 | const own = await read(oid); | |
| 80 | if (own.pages.length > 0) return own; | |
| 81 | // Only a commit this repository holds borrows anything. | |
| 82 | const stat = await readerFor(dir).stat(oid); | |
| 83 | if (!stat.ok || stat.value.type !== "commit") return own; | |
| 84 | const tip = await exactRefOid(dir, `refs/heads/${repo.defaultBranch}`); | |
| 85 | if (tip === undefined || tip === oid) return own; | |
| 86 | const now = await read(tip); | |
| 87 | return { pages: now.pages, ...((own.launch ?? now.launch) === undefined ? {} : { launch: own.launch ?? now.launch }) }; | |
| 88 | } | |
| 89 | ||
| 90 | /** The facts a pin's demo starts from, before anyone edits them. */ | |
| 91 | export function demoDefaults(repo: Repo, label: string, pages: readonly string[]): Pick<Demo, "name" | "version" | "page"> { | |
| 92 | return { name: repo.name, version: label, page: pages[0] ?? "/" }; | |
| 93 | } | |
| 94 | ||
| 95 | /** A person who writes the repository, never an agent, and not on a mirror: who may change a demo. */ | |
| 96 | export function writerOf(app: App, actor: User | undefined, repo: Repo): Result<User, AccessError | "mirrored"> { | |
| 97 | const writer = requireWriter(app, actor, repo); | |
| 98 | if (!writer.ok) return writer; | |
| 99 | if (writer.value.kind === "agent") return err("forbidden"); | |
| 100 | if (isMirror(app, repo)) return err("mirrored"); | |
| 101 | return writer; | |
| 102 | } | |
| 103 | ||
| 104 | /** | |
| 105 | * Set the pin's demo facts, keeping its cover. A demo that opens as a file | |
| 106 | * and has no capture of its page gets one started; `capture` asks for one | |
| 107 | * again after a failure. | |
| 108 | */ | |
| 109 | export async function saveDemo( | |
| 110 | app: App, | |
| 111 | actor: User | undefined, | |
| 112 | repo: Repo, | |
| 113 | label: string, | |
| 114 | input: DemoInput, | |
| 115 | options: CaptureOptions & { readonly capture?: boolean } = {}, | |
| 116 | ): Promise<Result<BuildPin, PinDemoError>> { | |
| 117 | const writer = writerOf(app, actor, repo); | |
| 118 | if (!writer.ok) return writer; | |
| 119 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 120 | const pin = await readBuildPin(dir, label); | |
| 121 | if (pin === undefined) return err("not_found"); | |
| 122 | const demo: Demo = { | |
| 123 | name: input.name.trim(), | |
| 124 | version: input.version.trim(), | |
| 125 | ...(input.launch === undefined ? {} : { launch: input.launch }), | |
| 126 | page: input.page.trim() || "/", | |
| 127 | showcase: input.showcase, | |
| 128 | ...(pin.demo?.cover === undefined ? {} : { cover: pin.demo.cover }), | |
| 129 | }; | |
| 130 | if (!isDemo(demo)) return err("invalid_demo"); | |
| 131 | const set = await setPinDemo(dir, label, demo); | |
| 132 | if (!set.ok) return set; | |
| 133 | app.bus.append({ | |
| 134 | kind: "build.demo", | |
| 135 | repoId: repo.id, | |
| 136 | actorId: writer.value.id, | |
| 137 | payload: { label, oid: pin.oid, demo: demo.name, version: demo.version, launch: demo.launch ?? null, showcase: demo.showcase }, | |
| 138 | }); | |
| 139 | const facts = await demoFactsAt(app, repo, pin.oid); | |
| 140 | if (launchOf(demo, facts.launch, facts.pages) === "file") { | |
| 141 | const state = captureState(app, repo, pin.oid, demo.page).state; | |
| 142 | if (state === "none" || (options.capture === true && state === "failed")) | |
| 143 | void startCapture(app, writer.value, repo, pin.oid, demo.page, options); | |
| 144 | } | |
| 145 | return ok(set.value); | |
| 146 | } | |
| 147 | ||
| 148 | /** Take the pin's demo off display pages; the demo and the pin stay. */ | |
| 149 | export async function unshowcaseDemo( | |
| 150 | app: App, | |
| 151 | actor: User | undefined, | |
| 152 | repo: Repo, | |
| 153 | label: string, | |
| 154 | ): Promise<Result<BuildPin, PinDemoError>> { | |
| 155 | const writer = writerOf(app, actor, repo); | |
| 156 | if (!writer.ok) return writer; | |
| 157 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 158 | const pin = await readBuildPin(dir, label); | |
| 159 | if (pin?.demo === undefined) return err("not_found"); | |
| 160 | if (!pin.demo.showcase) return ok(pin); | |
| 161 | const set = await setPinDemo(dir, label, { ...pin.demo, showcase: false }); | |
| 162 | if (!set.ok) return set; | |
| 163 | app.bus.append({ | |
| 164 | kind: "build.demo", | |
| 165 | repoId: repo.id, | |
| 166 | actorId: writer.value.id, | |
| 167 | payload: { label, oid: pin.oid, demo: pin.demo.name, version: pin.demo.version, showcase: false }, | |
| 168 | }); | |
| 169 | return ok(set.value); | |
| 170 | } | |
| 171 | ||
| 172 | /** | |
| 173 | * Put the pin in the showcase or take it out: the Iterations page's | |
| 174 | * Showcased chip. In: its demo as it stands, else a demo from the pin's | |
| 175 | * defaults, saved with the showcase switch on (`saveDemo`). Out: | |
| 176 | * `unshowcaseDemo`. The demo itself stays either way. | |
| 177 | */ | |
| 178 | export async function setShowcase( | |
| 179 | app: App, | |
| 180 | actor: User | undefined, | |
| 181 | repo: Repo, | |
| 182 | label: string, | |
| 183 | on: boolean, | |
| 184 | ): Promise<Result<BuildPin, PinDemoError>> { | |
| 185 | if (!on) return unshowcaseDemo(app, actor, repo, label); | |
| 186 | const writer = writerOf(app, actor, repo); | |
| 187 | if (!writer.ok) return writer; | |
| 188 | const pin = await readBuildPin(repoDir(app.reposRoot, repo.ownerName, repo.name), label); | |
| 189 | if (pin === undefined) return err("not_found"); | |
| 190 | if (pin.demo?.showcase === true) return ok(pin); | |
| 191 | const facts = pin.demo === undefined ? await demoFactsAt(app, repo, pin.oid) : undefined; | |
| 192 | const base = pin.demo ?? { ...demoDefaults(repo, label, facts?.pages ?? []), showcase: false }; | |
| 193 | return saveDemo(app, writer.value, repo, label, { | |
| 194 | name: base.name, | |
| 195 | version: base.version, | |
| 196 | ...(pin.demo?.launch === undefined ? {} : { launch: pin.demo.launch }), | |
| 197 | page: base.page, | |
| 198 | showcase: true, | |
| 199 | }); | |
| 200 | } | |
| 201 | ||
| 202 | /** | |
| 203 | * Clear the pin's demo: its tag is written again without the demo | |
| 204 | * trailers. The pin, its commit and its screenshots stay. | |
| 205 | */ | |
| 206 | export async function deleteDemo( | |
| 207 | app: App, | |
| 208 | actor: User | undefined, | |
| 209 | repo: Repo, | |
| 210 | label: string, | |
| 211 | ): Promise<Result<BuildPin, PinDemoError>> { | |
| 212 | const writer = writerOf(app, actor, repo); | |
| 213 | if (!writer.ok) return writer; | |
| 214 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 215 | const pin = await readBuildPin(dir, label); | |
| 216 | if (pin?.demo === undefined) return err("not_found"); | |
| 217 | const set = await setPinDemo(dir, label, undefined); | |
| 218 | if (!set.ok) return set; | |
| 219 | app.bus.append({ | |
| 220 | kind: "build.demo", | |
| 221 | repoId: repo.id, | |
| 222 | actorId: writer.value.id, | |
| 223 | payload: { label, oid: pin.oid, demo: null, previous: pin.demo.name, version: pin.demo.version }, | |
| 224 | }); | |
| 225 | return ok(set.value); | |
| 226 | } | |
| 227 | ||
| 228 | /** | |
| 229 | * Build a pin's page and keep it: its capture, started in the background | |
| 230 | * as the writer who asked (`startCapture`), when the pin opens as a file | |
| 231 | * and its page is not captured or its capture failed. What the Iterations | |
| 232 | * page's Build and play does; the page then waits on `captureState`. | |
| 233 | */ | |
| 234 | export async function captureBuild( | |
| 235 | app: App, | |
| 236 | actor: User | undefined, | |
| 237 | repo: Repo, | |
| 238 | label: string, | |
| 239 | options: CaptureOptions = {}, | |
| 240 | ): Promise<Result<CaptureState["state"], PinDemoError>> { | |
| 241 | const writer = writerOf(app, actor, repo); | |
| 242 | if (!writer.ok) return writer; | |
| 243 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 244 | const pin = await readBuildPin(dir, label); | |
| 245 | if (pin === undefined) return err("not_found"); | |
| 246 | const facts = await demoFactsAt(app, repo, pin.oid); | |
| 247 | if (launchOf(pin.demo, facts.launch, facts.pages) !== "file") return err("not_a_file"); | |
| 248 | const page = pin.demo?.page ?? demoDefaults(repo, label, facts.pages).page; | |
| 249 | const state = captureState(app, repo, pin.oid, page).state; | |
| 250 | if (state === "done" || state === "capturing") return ok(state); | |
| 251 | void startCapture(app, writer.value, repo, pin.oid, page, options); | |
| 252 | return ok("capturing"); | |
| 253 | } |
test/pin-demos.test.ts deleted −92
| @@ -1,92 +0,0 @@ | ||
| 1 | /** | |
| 2 | * A pin's demo (src/actions/pin_demos.ts): defaults from a label and pages, | |
| 3 | * the facts a pinned commit's forge.json names, and who may save one. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { demoDefaults, demoFactsAt, saveDemo } from "../src/actions/pin_demos.ts"; | |
| 11 | import { pinBuild } from "../src/actions/builds.ts"; | |
| 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 13 | import { getRepo } from "../src/core/repos.ts"; | |
| 14 | import { createUser, type User } from "../src/core/users.ts"; | |
| 15 | import { launchOf, readBuildPin } from "../src/git/builds.ts"; | |
| 16 | import { repoDir } from "../src/git/repos.ts"; | |
| 17 | ||
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let alice: User; | |
| 21 | let bob: User; | |
| 22 | let oid: string; | |
| 23 | const fresh = () => getRepo(app.db, "alice", "game")!; | |
| 24 | const input = { name: "Game", version: "v1", page: "/", showcase: false, launch: "env" as const }; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-pin-demos-")); | |
| 28 | app = createApp({ dataDir, port: 0 }); | |
| 29 | const users: User[] = []; | |
| 30 | for (const name of ["alice", "bob"]) { | |
| 31 | const made = await createUser(app.db, { | |
| 32 | origin: "seed", | |
| 33 | trusted: true, | |
| 34 | username: name, | |
| 35 | email: `${name}@forge.local`, | |
| 36 | password: "a-good-passphrase", | |
| 37 | adminIfFirst: name === "alice", | |
| 38 | }); | |
| 39 | if (!made.ok) throw new Error(made.error); | |
| 40 | users.push(made.value); | |
| 41 | } | |
| 42 | [alice, bob] = users as [User, User]; | |
| 43 | const made = await createRepo(app, alice, { name: "game", visibility: "public" }); | |
| 44 | if (!made.ok) throw new Error(String(made.error)); | |
| 45 | const forgeJson = JSON.stringify({ app: { run: "bun run x.ts", pages: ["/", "/spicy"], launch: "file" } }); | |
| 46 | const c = await commitToBranch(app, alice, fresh(), { | |
| 47 | branch: "main", | |
| 48 | message: "one", | |
| 49 | files: [{ path: "forge.json", content: forgeJson }, { path: "x.ts", content: "\n" }], | |
| 50 | }); | |
| 51 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 52 | oid = c.value.commitOid; | |
| 53 | const pinned = await pinBuild(app, alice, fresh(), { oid, label: "v1" }); | |
| 54 | if (!pinned.ok) throw new Error(String(pinned.error)); | |
| 55 | }); | |
| 56 | ||
| 57 | afterAll(async () => { | |
| 58 | app.close(); | |
| 59 | await rm(dataDir, { recursive: true, force: true }); | |
| 60 | }); | |
| 61 | ||
| 62 | test("demoDefaults names a demo from a label and the first page", () => { | |
| 63 | expect(demoDefaults(fresh(), "v1", ["/spicy", "/"])).toEqual({ name: "game", version: "v1", page: "/spicy" }); | |
| 64 | expect(demoDefaults(fresh(), "v2", []).page).toBe("/"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("demoFactsAt reads a pinned commit's pages and launch", async () => { | |
| 68 | expect(await demoFactsAt(app, fresh(), oid)).toEqual({ pages: ["/", "/spicy"], launch: "file" }); | |
| 69 | expect(await demoFactsAt(app, fresh(), "not-an-oid")).toEqual({ pages: [] }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("launchOf: the pin, then the app, then pages decide", () => { | |
| 73 | expect(launchOf({ launch: "shots" }, "file", ["/"])).toBe("shots"); | |
| 74 | expect(launchOf(undefined, "env", ["/"])).toBe("env"); | |
| 75 | expect(launchOf(undefined, undefined, ["/"])).toBe("file"); | |
| 76 | expect(launchOf(undefined, undefined, [])).toBe("env"); | |
| 77 | }); | |
| 78 | ||
| 79 | test("saveDemo by a non-writer is refused with the access error", async () => { | |
| 80 | const refused = await saveDemo(app, bob, fresh(), "v1", input); | |
| 81 | expect(refused).toEqual({ ok: false, error: "forbidden" }); | |
| 82 | const anon = await saveDemo(app, undefined, fresh(), "v1", input); | |
| 83 | expect(anon).toEqual({ ok: false, error: "unauthorized" }); | |
| 84 | expect((await readBuildPin(repoDir(app.reposRoot, "alice", "game"), "v1"))?.demo).toBeUndefined(); | |
| 85 | }); | |
| 86 | ||
| 87 | test("saveDemo by the writer keeps the demo on the pin", async () => { | |
| 88 | const saved = await saveDemo(app, alice, fresh(), "v1", input); | |
| 89 | expect(saved.ok).toBe(true); | |
| 90 | const pin = await readBuildPin(repoDir(app.reposRoot, "alice", "game"), "v1"); | |
| 91 | expect(pin?.demo).toMatchObject({ name: "Game", version: "v1", launch: "env", page: "/" }); | |
| 92 | }); |
new above
offline