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

Jay Moran's pin demos are on main: src/actions/pin_demos.ts, with launchOf in src/git/builds.ts #9284

Merged opened this

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

Merged

scout/forge:scout/9272-jay-moran-s-pin-demos-are-on-main-src-ac → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/9272-jay-moran-s-pin-demos-are-on-main-src-ac was merged into main as d6a65d8.

Review

Sign in to review this change.
5 files changed +395 −0
src/actions/pin_demos.ts added +253
@@ -0,0 +1,253 @@
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 }
src/apps/manifest.ts +36
@@ -11,6 +11,7 @@
1111 */
1212
1313 import { isIP } from "node:net";
14 import { isLaunch, LAUNCHES, type Launch } from "../git/builds.ts";
1415 import { err, ok, type Result } from "../kernel/result.ts";
1516 import { parseService, type ServiceSpec } from "./service.ts";
1617 import type { DeclaredLimits } from "./contain.ts";
@@ -39,6 +40,17 @@ export interface AppManifest {
3940 * before this field existed.
4041 */
4142 readonly looks: readonly string[];
43 /**
44 * `app.pages`: the pages a person plays or downloads, each a path the app
45 * serves as one self-contained page (`"/"`, `"/spicy"`). Empty or unset
46 * when the manifest names none.
47 */
48 readonly pages?: readonly string[] | undefined;
49 /**
50 * `app.launch`: how this repository's demos open unless a pin says
51 * otherwise (`launchOf`). Unset follows `launchOf`.
52 */
53 readonly launch?: Launch | undefined;
4254 /**
4355 * `app.looksAsOwner`: the pages of `looks` whose phone and desktop
4456 * pictures are taken signed in as the owner, even of a public app —
@@ -149,6 +161,11 @@ export function parseManifest(
149161 );
150162 const looks = looksField(app);
151163 if (!looks.ok) return looks;
164 const pages = pagesField(app);
165 if (!pages.ok) return pages;
166 const launch = app["launch"];
167 if (launch !== undefined && !isLaunch(launch))
168 return invalid(`forge.json: app.launch must be one of ${LAUNCHES.map((l) => `"${l}"`).join(", ")}`);
152169 const looksAsOwner = looksAsOwnerField(app, looks.value);
153170 if (!looksAsOwner.ok) return looksAsOwner;
154171 const egress = egressField(parsed.value);
@@ -184,6 +201,8 @@ export function parseManifest(
184201 tty: tty.value || null,
185202 looks: looks.value,
186203 looksAsOwner: looksAsOwner.value,
204 pages: pages.value,
205 ...(launch === undefined ? {} : { launch }),
187206 service,
188207 egress: egress.value,
189208 limits: limits.value,
@@ -268,6 +287,23 @@ function limitsField(
268287 return ok(out);
269288 }
270289
290 /** How many pages a manifest may offer to play. */
291 export const PAGES_MAX = 20;
292
293 function pagesField(app: Json): Result<readonly string[], ManifestError> {
294 const value = app["pages"];
295 if (value === undefined) return ok([]);
296 if (!Array.isArray(value) || value.length > PAGES_MAX)
297 return invalid(`forge.json: app.pages must be an array of at most ${PAGES_MAX} paths`);
outdated

This inserts PAGES_MAX and pagesField between looksField's doc comment (just above) and looksField itself, so that comment now documents PAGES_MAX. Move this block above the looks comment or below looksField.

298 const paths: string[] = [];
299 for (const item of value) {
300 if (typeof item !== "string" || !/^\/[A-Za-z0-9._-]*(\/[A-Za-z0-9._-]+)*$/.test(item) || item.length > 120 || item.includes(".."))
301 return invalid('forge.json: app.pages entries are paths like "/" or "/spicy": letters, digits, dot, dash, underscore');
302 if (!paths.includes(item)) paths.push(item);
303 }
304 return ok(paths);
305 }
306
271307 /**
272308 * `app.looks`: the pages worth a picture. Absent is `["/"]`, exactly as
273309 * before this field existed. Validated like any other boundary input — a
src/core/event_audience.ts +1
@@ -135,6 +135,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
135135 "build.pinned",
136136 "build.deleted",
137137 "build.noted",
138 "build.demo",
138139 // A pin's screenshots are read by its repository's readers (`actions/build_shots.ts`).
139140 "build.shots",
140141 "invite.used",
src/git/builds.ts +13
@@ -66,6 +66,19 @@ export interface Demo {
6666 readonly cover?: string | undefined;
6767 }
6868
69 /**
70 * How a demo opens: its pin's `Launch:`, else the repository's
71 * `app.launch`, else `file` when the app names pages to play and `env`
72 * when it names none.
73 */
74 export function launchOf(
75 demo: Pick<Demo, "launch"> | undefined,
76 appLaunch: Launch | undefined,
77 pages: readonly string[],
78 ): Launch {
79 return demo?.launch ?? appLaunch ?? (pages.length > 0 ? "file" : "env");
80 }
81
6982 export const COVERS_PREFIX = "refs/covers/";
7083
7184 /** A demo fact: one plain line, at most `max` characters. */
test/pin-demos.test.ts added +92
@@ -0,0 +1,92 @@
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs