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

Forge packs: deploy a premade forge, such as Games or Business, and start contributing to public apps #9528

Merged opened this in answer to

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

Merged

mason/forge:mason/9428-forge-packs-deploy-a-premade-forge-such → main 5 commits fork 2 threads · 2 unresolved

  • Merged. mason/9428-forge-packs-deploy-a-premade-forge-such was merged into main as 47c57bd.

Review

Sign in to review this change.
25 files changed +949 −6
AGENTS.md +1 −1
@@ -8,7 +8,7 @@ When you write an issue, end it with a `Files:` line naming the files the work t
88
99 A new `forge` command is a new file in `src/cli/commands/` (exporting `command`) plus one line in its `index.ts`.
1010
11 A new terminal screen offered only to site admins exports its palette `entry` from its own module and adds one line to `ENTRIES` in `src/tui/screens/entries.ts`; `after` names the commands it follows. Entries offered to everyone, and entries gated by a helper (`mayCapacity`, `mayManageFlags`, `canSeeMemory`, `telemetryReader` and the like) rather than by `isAdmin`, stay in `forgeCommands` in `src/tui/forge.ts`.
11 A new terminal screen offered only to site admins exports its palette `entry` from its own module and adds one line to `ENTRIES` in `src/tui/screens/entries.ts`; `after` names the commands it follows. Entries offered to everyone (the packs screen, `src/tui/screens/packs.ts`, is one), and entries gated by a helper (`mayCapacity`, `mayManageFlags`, `canSeeMemory`, `telemetryReader` and the like) rather than by `isAdmin`, stay in `forgeCommands` in `src/tui/forge.ts`.
1212
1313 A new read of a table that grows (`event`, `issue`, `run`, `comment`) names the index it walks, in the migration that adds it, and a test shows it from the query plan (`test/hot-reads-indexed.test.ts` is the pattern): no `SCAN`, no `TEMP B-TREE`. Judge by plan or row count, never the clock. A read that stays slow with the best index is a summary table, kept as the rows change, before it is a worker.
1414
docs/design/audit.md +1
@@ -64,6 +64,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
6464 - `/talk` — `pages/talk.ts` · the person's one conversation with the forge: history, composer, Confirm and Cancel; replies stream in over `/talk/stream` · `/settings/confirms` (the same yes, as a list), the home tell box (one line to an app's crew) · none seen; styles in `docs/design/areas/talk.md`.
6565 - `/notes` — `pages/notes.ts` · what the person wrote, newest first, with the planner's verdict and live state in one line, and one tap to overrule it · the home tell box (Notes button), the user menu, the palette · none seen; styles in `docs/design/areas/notes.md`.
6666 - `/pipeline` — `pages/pipeline.ts` · the four steps a change follows (check, review, land, live), for a newcomer · a pull page's step line, `/flow` · none seen.
67 - `/packs` — `pages/packs.ts` · the premade forges this forge ships, what each brings, how to deploy it and how a stranger helps build its apps · the user menu, the signed-out menu, the palette · none seen; uses the shared card, list and button components.
6768 - `/map` — `pages/map.ts` (1,806 lines) · the dependency map of work across the forge · `/board`, issue dependency lists, `/:owner/:name/map` · colours 3, sizes 12: the largest private vocabulary.
6869 - `/audit` — `pages/audit.ts` · every app's contract, who can do what · the app page's one-line policy · none seen. Names the audit of apps, not this document.
6970 - `/repos` — `pages/my-repos.ts` · the repositories you may write, from the account menu · `/` (your apps), `/:owner` (a profile's repositories) · none seen.
docs/features/packs.md added +12
@@ -0,0 +1,12 @@
1 # Forge packs: a premade forge to deploy (`/packs`)
2
3 A pack is a `pack.json` in a repository (`src/packs/pack.ts` holds the format, version 1): a name, title, semver version, description, the **apps** it brings (each an https address of a public git repository, with a name and description) and the **crew** it starts with (username, roles of planner/builder/reviewer, a brief, optionally a model and a per-run budget). Anyone can publish one. A pack may not carry data, secrets or variables: any key that names one (`secret`, `env`, `token`, `data`, `key`…) at any depth is refused, not ignored.
4
5 Two ship in `packs/`: **games** (2048, hextris, react-tetris) and **business** (memos, kanboard), each with a crew.
6
7 - **See one.** `/packs` lists them, `/packs/<name>` says what it brings, the deploy commands, and how a stranger helps build its apps (get a guest account or use their own forge; a guest's pull waits for a person, and paths touching secrets, credentials, permissions or release machinery are named on it — `docs/ROLES.md`). Reached from the user menu, the signed-out menu and the palette. `bun scripts/pack.ts show games` prints the same in a terminal, and the SSH terminal has a **Forge packs** screen (palette, `src/tui/screens/packs.ts`): the list, a pack's apps and crew, the deploy commands and what happens to a stranger's pull. The words are shared in `src/packs/words.ts`.
8 - **Deploy one.** Boot a forge (`bun run src/main.ts`), make an account and token, then `FORGE_URL=… FORGE_TOKEN=forge_… bun scripts/pack.ts deploy games`. Each app is made, the source's whole history (branches and tags) is pushed in, and the crew is added. Run it again to finish a partial deploy: what exists is kept. The model credential is yours to add in Settings.
9 - **Nightly.** `scripts/pack-compat.ts` boots a clean forge per pack, deploys it with the real sources and fails if any step or app page fails (job `pack-compat`, `src/nightly/jobs.ts`).
10 - Code: `src/packs/`, `src/web/pages/packs.ts`, `scripts/pack.ts`. Tests: `test/packs.test.ts`, `test/packs-hands.test.ts` (the git credential is scoped to the forge and never sent to a pack's source; only 409 means "exists"; the terminal screen).
11
12 Not done yet: a hosted "deploy this pack" press, a stable follow link from each app back to its source, the guest sign-up gate and the crew filtering guest pulls on automode (owner decisions on #9428).
packs/business/pack.json added +16
@@ -0,0 +1,16 @@
1 {
2 "pack": 1,
3 "name": "business",
4 "title": "Business",
5 "version": "1.0.0",
6 "description": "The everyday tools of a small business: notes and a task board, two open-source apps with their history, and a crew that plans, builds and reviews changes you ask for.",
7 "apps": [
8 { "name": "memos", "description": "Quick notes and a shared journal for a team.", "source": "https://github.com/usememos/memos" },
9 { "name": "kanboard", "description": "A simple board for tasks and projects.", "source": "https://github.com/kanboard/kanboard" }
10 ],
11 "crew": [
12 { "username": "office-planner", "roles": ["planner"], "brief": "You turn what the business asks for into small, clear steps. Prefer the change that saves someone time this week." },
13 { "username": "office-builder", "roles": ["builder"], "brief": "You make the changes the planner lays out, in small steps, with tests where the app has them." },
14 { "username": "office-reviewer", "roles": ["reviewer"], "brief": "You review changes for a business that keeps customer information. Refuse anything that touches secrets or leaks data." }
15 ]
16 }
packs/games/pack.json added +16
@@ -0,0 +1,16 @@
1 {
2 "pack": 1,
3 "name": "games",
4 "title": "Games",
5 "version": "1.0.0",
6 "description": "A small arcade you can play today and change tomorrow: three well-loved open-source games, each with its history, and a crew that keeps them tidy.",
7 "apps": [
8 { "name": "2048", "description": "Slide tiles and merge them to reach 2048.", "source": "https://github.com/gabrielecirulli/2048" },
9 { "name": "hextris", "description": "A fast puzzle on a spinning hexagon.", "source": "https://github.com/Hextris/hextris" },
10 { "name": "react-tetris", "description": "Tetris in the browser, with sound.", "source": "https://github.com/chvin/react-tetris" }
11 ],
12 "crew": [
13 { "username": "arcade-builder", "roles": ["builder"], "brief": "You build and fix small browser games. Keep them fast, playable on a phone and fun. Change one thing at a time." },
14 { "username": "arcade-reviewer", "roles": ["reviewer"], "brief": "You review changes to games. Check that the game still plays, nothing gets slower, and the change is small enough to follow." }
15 ]
16 }
scripts/pack-compat.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * `bun scripts/pack-compat.ts` — does every shipped pack deploy on a clean
3 * forge? For each pack it boots a new forge on an empty data directory, makes
4 * an account and a token, deploys the pack as `scripts/pack.ts` does (so the
5 * apps' real sources are cloned over the network) and checks every step
6 * succeeded and each app's page answers. Exit 1 and the failing steps
7 * otherwise. Run nightly (`src/nightly/jobs.ts`, job `pack-compat`).
8 */
9
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp } from "../src/app.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve } from "../src/http/server.ts";
17 import { catalog } from "../src/packs/catalog.ts";
18 import { deployPack } from "../src/packs/deploy.ts";
19 import { realHands } from "./pack.ts";
20
21 const { packs, problems } = catalog();
22 let failed = problems.length;
23 for (const p of problems) console.error(`✗ ${p}`);
24
25 for (const pack of packs) {
26 const dataDir = await mkdtemp(join(tmpdir(), `pack-compat-${pack.name}-`));
27 const app = createApp({ dataDir, port: 0 });
28 const servers = await serve(app);
29 try {
30 const made = await createUser(app.db, {
31 origin: "seed",
32 trusted: true,
33 allowReserved: true,
34 username: "owner",
35 email: "[email protected]",
36 password: "a-good-passphrase",
37 adminIfFirst: true,
38 });
39 if (!made.ok) throw new Error(made.error);
40 const token = createToken(app.db, made.value.id, "pack-compat", "rw").token;
41 const steps = await deployPack(pack, realHands(servers.url, token));
42 for (const s of steps) {
43 const ok = s.state === "done";
44 if (!ok) failed++;
45 console.log(`${ok ? "✓" : "✗"} ${pack.name}: ${s.what}${s.note ? ` — ${s.note}` : ""}`);
46 }
47 for (const a of pack.apps) {
48 const res = await fetch(`${servers.url}/owner/${a.name}`);
49 await res.arrayBuffer();
50 if (res.status !== 200) {
51 failed++;
52 console.log(`✗ ${pack.name}: /owner/${a.name} answered ${res.status}`);
53 }
54 }
55 } finally {
56 servers.stop();
57 app.close();
58 await rm(dataDir, { recursive: true, force: true });
59 }
60 }
61 process.exit(failed === 0 ? 0 : 1);
scripts/pack.ts added +121
@@ -0,0 +1,121 @@
1 /**
2 * `bun scripts/pack.ts <show|deploy> <pack>` — a forge pack on your machine.
3 *
4 * bun scripts/pack.ts show games
5 * FORGE_URL=http://localhost:3000 FORGE_TOKEN=forge_… bun scripts/pack.ts deploy games
6 *
7 * `<pack>` is a shipped pack's name, or the path of a pack.json. `deploy` makes
8 * each app in the forge at FORGE_URL (the token's owner owns them), copies the
9 * source's whole history in, and adds the crew. It brings no data, secret or
10 * variable, and leaves the model credential to you. Run it again to finish a
11 * partial deploy: what exists is kept. Docs: docs/features/packs.md.
12 */
13
14 import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
15 import { tmpdir } from "node:os";
16 import { join } from "node:path";
17 import { ForgeApi } from "../src/cli/api.ts";
18 import { packNamed } from "../src/packs/catalog.ts";
19 import { deployPack, type Hands } from "../src/packs/deploy.ts";
20 import { parsePack, type Pack } from "../src/packs/pack.ts";
21
22 function load(arg: string): Pack | string {
23 if (/^[a-z][a-z0-9-]*$/.test(arg) && !existsSync(arg)) return packNamed(arg) ?? `no pack named ${arg}`;
24 if (!existsSync(arg)) return `no such file: ${arg}`;
25 const parsed = parsePack(readFileSync(arg, "utf8"));
26 return parsed.ok ? parsed.value : parsed.error;
27 }
28
29 const baseEnv = (): Record<string, string> => ({
30 PATH: process.env.PATH ?? "/usr/bin:/bin",
31 HOME: tmpdir(),
32 GIT_CONFIG_GLOBAL: "/dev/null",
33 GIT_CONFIG_SYSTEM: "/dev/null",
34 GIT_TERMINAL_PROMPT: "0",
35 });
36
37 /** The environment for cloning a pack's source: a stranger's host, so no credential at all. */
outdated

HIGH: this Authorization header applies to every host git talks to, including the pack's source on clone. Scope it to the forge URL (http.<url>/.extraHeader) and give the clone no credential.

38 export const cloneEnv = (): Record<string, string> => baseEnv();
39
40 /**
41 * The environment for the push: the credential is scoped to the forge's own address
42 * (`http.<url>/.extraHeader`), so git sends it nowhere else. It rides in the
43 * environment, never in an argument another process could list.
44 */
45 export const pushEnv = (url: string, token: string): Record<string, string> => ({
46 ...baseEnv(),
47 GIT_CONFIG_COUNT: "1",
48 GIT_CONFIG_KEY_0: `http.${url.replace(/\/+$/, "")}/.extraHeader`,
49 GIT_CONFIG_VALUE_0: `Authorization: Basic ${btoa(`x:${token}`)}`,
50 });
51
52 async function git(args: string[], env: Record<string, string>, cwd?: string): Promise<string | null> {
53 const p = Bun.spawn(["git", ...args], { env, ...(cwd === undefined ? {} : { cwd }), stdout: "ignore", stderr: "pipe" });
54 const [code, errText] = await Promise.all([p.exited, new Response(p.stderr).text()]);
55 return code === 0 ? null : errText.trim().split("\n").at(-1) ?? "git failed";
56 }
57
58 export function realHands(url: string, token: string): Hands {
59 const api = new ForgeApi(url, token);
60 return {
61 async makeApp(name, description) {
62 const made = await api.newApp({ name, description, visibility: "public" });
63 if (made.ok) return { ok: true, owner: made.value.pin.owner };
64 const e = made.error;
65 return { ok: false, exists: e.status === 409, why: e.message };
66 },
67 async carryHistory(source, owner, name, branch) {
68 const dir = mkdtempSync(join(tmpdir(), "pack-"));
69 try {
70 const bare = join(dir, "src.git");
71 // Source history is fetched without hooks or templates and pushed as plain branches and tags.
72 const cloned = await git(["clone", "--bare", "--quiet", ...(branch === undefined ? [] : ["--branch", branch]), source, bare], cloneEnv());
73 if (cloned !== null) return { ok: false, why: cloned };
74 const pushed = await git(
75 ["push", "--quiet", "--force", `${url}/${owner}/${name}.git`, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"],
76 pushEnv(url, token),
77 bare,
78 );
79 return pushed === null ? { ok: true } : { ok: false, why: pushed };
80 } finally {
outdated

422 is a validation failure, not 'exists'. Treating it as kept hides a missing crew member. Only 409 is 'exists'.

81 rmSync(dir, { recursive: true, force: true });
82 }
83 },
84 async makeAgent(a) {
85 const r = await fetch(`${url}/api/v1/agents`, {
86 method: "POST",
87 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
88 body: JSON.stringify({ username: a.username, roles: a.roles, brief: a.brief, ...(a.model ? { model: a.model } : {}), ...(a.budgetUsd !== undefined ? { budgetUsd: a.budgetUsd } : {}) }),
89 }).catch(() => null);
90 if (r === null) return { ok: false, exists: false, why: `cannot reach ${url}` };
91 if (r.ok) return { ok: true };
92 return { ok: false, exists: r.status === 409, why: `the forge answered ${r.status}` };
93 },
94 };
95 }
96
97 if (import.meta.main) {
98 const [verb, which] = process.argv.slice(2);
99 const pack = which === undefined ? "usage: bun scripts/pack.ts <show|deploy> <pack>" : load(which);
100 if (typeof pack === "string" || (verb !== "show" && verb !== "deploy")) {
101 console.error(typeof pack === "string" ? pack : "usage: bun scripts/pack.ts <show|deploy> <pack>");
102 process.exit(2);
103 }
104 if (verb === "show") {
105 console.log(`${pack.title} ${pack.version}\n${pack.description}\n`);
106 for (const a of pack.apps) console.log(` app ${a.name.padEnd(14)} ${a.source}`);
107 for (const c of pack.crew) console.log(` crew ${c.username.padEnd(14)} ${c.roles.join(", ")}`);
108 process.exit(0);
109 }
110 const url = (process.env.FORGE_URL ?? "").replace(/\/+$/, "");
111 const token = process.env.FORGE_TOKEN ?? "";
112 if (url === "" || token === "") {
113 console.error("deploy needs FORGE_URL and FORGE_TOKEN: the forge to fill and a token of the account that will own the apps");
114 process.exit(2);
115 }
116 const steps = await deployPack(pack, realHands(url, token));
117 for (const s of steps) console.log(`${s.state === "done" ? "✓" : s.state === "kept" ? "·" : "✗"} ${s.what}${s.note ? ` — ${s.note}` : ""}`);
118 const failed = steps.some((s) => s.state === "failed");
119 console.log(failed ? "\nSome steps failed; run it again to finish." : `\n${pack.title} is deployed. Add your model credential in Settings to start the crew.`);
120 process.exit(failed ? 1 : 0);
121 }
src/core/reserved.ts +1
@@ -70,6 +70,7 @@ export const SYSTEM_NAMES: readonly string[] = [
7070 "events",
7171 "bring",
7272 "explore",
73 "packs",
7374 "family",
7475 "favicon.ico",
7576 "fleet",
src/http/server.ts +4
@@ -268,6 +268,7 @@ import { liveChipsFragment, liveMovingFragment } from "../web/studio/living.ts";
268268 import { HOME_REGIONS } from "../web/studio/home_last.ts";
269269 import { serviceWorker } from "../web/sw.ts";
270270 import { explorePage } from "../web/studio/explore.ts";
271 import { packPage, packsPage } from "../web/pages/packs.ts";
271272 import {
272273 makeItYoursPage,
273274 makeItYoursSubmit,
@@ -762,6 +763,9 @@ export function buildRouter(): Router<Handler> {
762763 router.add("GET", "/front", studioDoor);
763764 // Apps and people ranked by real numbers, narrowed in one line (#607).
764765 router.add("GET", "/explore", explorePage);
766 // Forge packs: premade forges to deploy, and the way to help build their apps.
767 router.add("GET", "/packs", packsPage);
768 router.add("GET", "/packs/:name", packPage);
765769 router.add("POST", "/front", frontUpdate);
766770 router.add("POST", "/home/archive", homeArchive);
767771 router.add("POST", "/flow/keeper/answer", keeperAnswer);
src/nightly/jobs.ts +14 −1
@@ -17,7 +17,7 @@ export interface Step {
1717 }
1818
1919 export interface Job {
20 readonly name: "outside-gate" | "flake-hunt";
20 readonly name: "outside-gate" | "flake-hunt" | "pack-compat";
2121 readonly slot: Slot;
2222 readonly steps: readonly Step[];
2323 /** After the steps: the file whose quarantined edit is proposed as a pull. */
@@ -72,4 +72,17 @@ export const JOBS: readonly Job[] = [
7272 },
7373 ],
7474 },
75 {
76 name: "pack-compat",
77 slot: { hour: 4, minute: 37 },
78 steps: [
79 install,
80 {
81 label: "deploy each pack on a clean forge",
82 command: "bun scripts/pack-compat.ts",
83 timeoutMs: 30 * 60_000,
84 reports: false,
85 },
86 ],
87 },
7588 ];
src/packs/catalog.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * The packs this forge ships: every `packs/<name>/pack.json` in the
3 * repository, parsed once. A file that fails to parse is left out and named
4 * in `problems`, which a test keeps empty, so a bad pack never reaches a page.
5 */
6
7 import { existsSync, readdirSync, readFileSync } from "node:fs";
8 import { join } from "node:path";
9 import { parsePack, type Pack } from "./pack.ts";
10
11 const ROOT = join(import.meta.dir, "..", "..", "packs");
12
13 export function loadCatalog(root = ROOT): { packs: readonly Pack[]; problems: readonly string[] } {
14 const packs: Pack[] = [];
15 const problems: string[] = [];
16 if (!existsSync(root)) return { packs, problems };
17 for (const dir of readdirSync(root, { withFileTypes: true })) {
18 if (!dir.isDirectory()) continue;
19 const file = join(root, dir.name, "pack.json");
20 if (!existsSync(file)) continue;
21 const parsed = parsePack(readFileSync(file, "utf8"));
22 if (!parsed.ok) problems.push(`${dir.name}: ${parsed.error}`);
23 else if (parsed.value.name !== dir.name) problems.push(`${dir.name}: name must match the directory`);
24 else packs.push(parsed.value);
25 }
26 packs.sort((a, b) => a.name.localeCompare(b.name));
27 return { packs, problems };
28 }
29
30 let cached: ReturnType<typeof loadCatalog> | undefined;
31 /** The shipped catalog, read once per process. */
32 export const catalog = (): ReturnType<typeof loadCatalog> => (cached ??= loadCatalog());
33
34 export const packNamed = (name: string): Pack | undefined => catalog().packs.find((p) => p.name === name);
src/packs/deploy.ts added +57
@@ -0,0 +1,57 @@
1 /**
2 * Deploy a pack into a running forge: for each app, make it, then carry the
3 * source's whole history into it; then add the crew. The doing is handed in
4 * (`Hands`), so this module is the order and the reporting, and a test can
5 * watch it with fakes. An app that fails is reported and the rest go on;
6 * running it again skips what already exists, so a half-deployed pack is
7 * finished by deploying it again. No data, secret or variable is read from the
8 * source, and the model credential is left for the person to supply.
9 */
10
11 import type { Pack } from "./pack.ts";
12
13 export interface Hands {
14 /** Make an app; `exists` when the forge already has one of that name. */
15 makeApp(name: string, description: string): Promise<{ ok: true; owner: string } | { ok: false; exists: boolean; why: string }>;
16 /** Copy every branch and tag of `source` into the app, history whole. */
17 carryHistory(source: string, owner: string, name: string, branch: string | undefined): Promise<{ ok: true } | { ok: false; why: string }>;
18 makeAgent(a: Pack["crew"][number]): Promise<{ ok: true } | { ok: false; exists: boolean; why: string }>;
19 }
20
21 export interface Step {
22 readonly what: string;
23 readonly state: "done" | "kept" | "failed";
24 readonly note?: string;
25 }
26
27 export async function deployPack(pack: Pack, hands: Hands): Promise<readonly Step[]> {
28 const steps: Step[] = [];
29 for (const app of pack.apps) {
30 const made = await hands.makeApp(app.name, `${app.description} (from ${app.source})`);
31 if (!made.ok) {
32 steps.push(
33 made.exists
34 ? { what: `app ${app.name}`, state: "kept", note: "already here" }
35 : { what: `app ${app.name}`, state: "failed", note: made.why },
36 );
37 continue;
38 }
39 const carried = await hands.carryHistory(app.source, made.owner, app.name, app.branch);
40 steps.push(
41 carried.ok
42 ? { what: `app ${app.name}`, state: "done", note: `history from ${app.source}` }
43 : { what: `app ${app.name}`, state: "failed", note: `made, history not carried: ${carried.why}` },
44 );
45 }
46 for (const member of pack.crew) {
47 const made = await hands.makeAgent(member);
48 steps.push(
49 made.ok
50 ? { what: `crew ${member.username}`, state: "done", note: member.roles.join(", ") }
51 : made.exists
52 ? { what: `crew ${member.username}`, state: "kept", note: "already here" }
53 : { what: `crew ${member.username}`, state: "failed", note: made.why },
54 );
55 }
56 return steps;
57 }
src/packs/pack.ts added +155
@@ -0,0 +1,155 @@
1 /**
2 * A forge pack: a plain, versioned definition of a forge to start from — the
3 * apps it brings (by public source), the crew it starts with, and a
4 * description. Anyone can publish one as a `pack.json` in a repository. Pure:
5 * `parsePack` reads text and returns a pack or the one sentence that says what
6 * is wrong. A pack carries no data, secret or variable: any key that looks
7 * like one is refused, never ignored, so a published pack cannot ask for them.
8 */
9
10 import { err, ok, type Result } from "../kernel/result.ts";
11 import { CREW_ROLES, type CrewRole } from "../core/agents.ts";
12
13 export const PACK_FORMAT = 1;
14 const NAME = /^[a-z][a-z0-9-]{0,39}$/;
15 const APP_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
16 const USERNAME = /^[a-z][a-z0-9-]{0,31}$/;
17 const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/;
18 const REPO_PART = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
19 const MAX_APPS = 20;
20 const MAX_CREW = 10;
21 const MAX_TEXT = 2_000;
22 /** Keys a pack may never carry, at any depth: what a pack brings is code and crew only. */
23 const FORBIDDEN = /secret|password|token|credential|env|variable|data|key/i;
24
25 export interface PackApp {
26 readonly name: string;
27 readonly description: string;
28 /** An https address of a public git repository, no credentials in it. */
29 readonly source: string;
30 readonly branch?: string;
31 }
32
33 export interface PackCrew {
34 readonly username: string;
35 readonly roles: readonly CrewRole[];
36 readonly brief: string;
37 readonly model?: string;
38 readonly budgetUsd?: number;
39 }
40
41 export interface Pack {
42 readonly format: typeof PACK_FORMAT;
43 readonly name: string;
44 readonly title: string;
45 readonly version: string;
46 readonly description: string;
47 readonly apps: readonly PackApp[];
48 readonly crew: readonly PackCrew[];
49 }
50
51 const text = (v: unknown, max = MAX_TEXT): string | undefined =>
52 typeof v === "string" && v.trim() !== "" && v.length <= max && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v)
53 ? v.trim()
54 : undefined;
55
56 /** The source's address if it names a public https repository, else undefined. */
57 export function sourceOf(v: unknown): string | undefined {
58 const raw = text(v, 300);
59 const url = raw === undefined ? null : URL.parse(raw);
60 if (url === null || url.protocol !== "https:" || url.username !== "" || url.password !== "") return undefined;
61 if (url.search !== "" || url.hash !== "") return undefined;
62 const parts = url.pathname.split("/").filter((s) => s !== "");
63 if (parts.length !== 2) return undefined;
64 const repo = parts[1]!.replace(/\.git$/, "");
65 if (!REPO_PART.test(parts[0]!) || !REPO_PART.test(repo)) return undefined;
66 return `https://${url.host.toLowerCase()}/${parts[0]}/${repo}`;
67 }
68
69 /** The first forbidden key anywhere in `v`, as a path, or undefined. */
70 function forbiddenKey(v: unknown, path = ""): string | undefined {
71 if (Array.isArray(v)) {
72 for (const [i, item] of v.entries()) {
73 const hit = forbiddenKey(item, `${path}[${i}]`);
74 if (hit !== undefined) return hit;
75 }
76 } else if (v !== null && typeof v === "object") {
77 for (const [k, item] of Object.entries(v)) {
78 if (FORBIDDEN.test(k)) return path === "" ? k : `${path}.${k}`;
79 const hit = forbiddenKey(item, path === "" ? k : `${path}.${k}`);
80 if (hit !== undefined) return hit;
81 }
82 }
83 return undefined;
84 }
85
86 const bad = (m: string): Result<Pack, string> => err(`pack: ${m}`);
87
88 /** Read a pack's text. Every field is checked; nothing unknown rides along. */
89 export function parsePack(raw: string): Result<Pack, string> {
90 let v: unknown;
91 try {
92 v = JSON.parse(raw);
93 } catch {
94 return bad("not JSON");
95 }
96 if (v === null || typeof v !== "object" || Array.isArray(v)) return bad("expected an object");
97 const o = v as Record<string, unknown>;
98 const hit = forbiddenKey(o);
99 if (hit !== undefined)
100 return bad(`"${hit}" is not allowed: a pack brings no data, secrets or variables`);
101 if (o.pack !== PACK_FORMAT) return bad(`"pack" must be ${PACK_FORMAT}`);
102 const name = typeof o.name === "string" && NAME.test(o.name) ? o.name : undefined;
103 if (name === undefined) return bad(`"name" must be lowercase letters, digits and dashes`);
104 const title = text(o.title, 80);
105 if (title === undefined) return bad(`"title" is missing`);
106 const version = typeof o.version === "string" && VERSION.test(o.version) ? o.version : undefined;
107 if (version === undefined) return bad(`"version" must look like 1.0.0`);
108 const description = text(o.description);
109 if (description === undefined) return bad(`"description" is missing`);
110
111 if (!Array.isArray(o.apps) || o.apps.length === 0 || o.apps.length > MAX_APPS)
112 return bad(`"apps" must list 1 to ${MAX_APPS} apps`);
113 const apps: PackApp[] = [];
114 for (const a of o.apps as unknown[]) {
115 const r = (a ?? {}) as Record<string, unknown>;
116 const appName = typeof r.name === "string" && APP_NAME.test(r.name) ? r.name : undefined;
117 const source = sourceOf(r.source);
118 const about = text(r.description, 300);
119 if (appName === undefined || source === undefined || about === undefined)
120 return bad(`each app needs a name, a description and an https "source" naming a public repository`);
121 if (apps.some((x) => x.name === appName)) return bad(`two apps are named "${appName}"`);
122 const branch = r.branch === undefined ? undefined : text(r.branch, 100);
123 if (r.branch !== undefined && (branch === undefined || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch)))
124 return bad(`app "${appName}" has an unusable "branch"`);
125 apps.push({ name: appName, description: about, source, ...(branch === undefined ? {} : { branch }) });
126 }
127
128 const crew: PackCrew[] = [];
129 const asked = o.crew === undefined ? [] : o.crew;
130 if (!Array.isArray(asked) || asked.length > MAX_CREW) return bad(`"crew" must list at most ${MAX_CREW} agents`);
131 for (const c of asked as unknown[]) {
132 const r = (c ?? {}) as Record<string, unknown>;
133 const username = typeof r.username === "string" && USERNAME.test(r.username) ? r.username : undefined;
134 const brief = text(r.brief, 4_000);
135 const roles = Array.isArray(r.roles) && r.roles.length > 0 ? r.roles : undefined;
136 if (username === undefined || brief === undefined || roles === undefined)
137 return bad(`each crew member needs a username, roles and a brief`);
138 if (!roles.every((x): x is CrewRole => CREW_ROLES.includes(x as CrewRole)))
139 return bad(`crew roles are ${CREW_ROLES.join(", ")}`);
140 if (crew.some((x) => x.username === username)) return bad(`two crew members are named "${username}"`);
141 const model = r.model === undefined ? undefined : text(r.model, 80);
142 if (r.model !== undefined && model === undefined) return bad(`crew "${username}" has an unusable "model"`);
143 const budget = r.budgetUsd;
144 if (budget !== undefined && (typeof budget !== "number" || !(budget >= 0 && budget <= 100)))
145 return bad(`crew "${username}" has an unusable "budgetUsd"`);
146 crew.push({
147 username,
148 roles: [...new Set(roles)],
149 brief,
150 ...(model === undefined ? {} : { model }),
151 ...(budget === undefined ? {} : { budgetUsd: budget }),
152 });
153 }
154 return ok({ format: PACK_FORMAT, name, title, version, description, apps, crew });
155 }
src/packs/words.ts added +25
@@ -0,0 +1,25 @@
1 /**
2 * What a pack's page says, in one place for the web page and the terminal
3 * screen: the commands that deploy it, and what happens to a stranger's pull.
4 */
5
6 import type { Pack } from "./pack.ts";
7
8 /** The commands, one per line. `origin` is the forge's configured public address, never a request's Host. */
9 export function deployCommands(p: Pack, origin: string | undefined): readonly string[] {
10 const from = origin === undefined ? "<this forge's address>" : origin.replace(/\/+$/, "");
11 return [
12 `git clone ${from}/trevato/forge.git && cd forge && bun install`,
13 `bun run src/main.ts # your forge, on http://localhost:3000`,
14 `# make an account there, create an access token, then:`,
15 `FORGE_URL=http://localhost:3000 FORGE_TOKEN=forge_… bun scripts/pack.ts deploy ${p.name}`,
16 ];
17 }
18
19 export const STRANGER_WORDS: readonly string[] = [
20 "You can read and comment with no account. To send a change, get a guest account here, or use a forge of your own: fork an app, change it, and send a pull.",
21 "A guest's pull waits for a person to review it. Any path it touches that holds secrets, credentials, permissions or release machinery is named on its page, and it cannot change them by landing.",
22 ];
23
24 export const BRINGS_WORDS =
25 "Each app comes with its history. No data, secrets or variables come with a pack, and the crew waits for the model credential you give it.";
src/tui/forge.ts +2
@@ -14,6 +14,7 @@ import { cleanQuery } from "../core/search.ts";
1414 import { guestCodeSearch } from "../http/strangers.ts";
1515 import { canRead, getRepo, type Repo } from "../core/repos.ts";
1616 import { sourceFor } from "../view/source.ts";
17 import { packsCommand } from "./screens/packs.ts";
1718 import { App, type Ctx, type Screen } from "./app/app.ts";
1819 import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts";
1920 import { G } from "./term/screen.ts";
@@ -191,6 +192,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
191192 hint: "on the web",
192193 run: () => openOnWeb(session, ctx, "/explore", "The explorer"),
193194 },
195 packsCommand(session, ctx),
194196 {
195197 // A person's or an org's page is the web's (#636): the terminal
196198 // hands over its address.
src/tui/screens/packs.ts added +98
@@ -0,0 +1,98 @@
1 /**
2 * `/packs` in the terminal: the premade forges this forge ships, one pack's
3 * contents (apps, crew), the commands that deploy it and what happens to a
4 * stranger's pull. Words come from `src/packs/words.ts`, the same the web page
5 * prints. Anyone may open it; it reads the catalog, nothing of the forge's.
6 */
7
8 import { catalog } from "../../packs/catalog.ts";
9 import type { Pack } from "../../packs/pack.ts";
10 import { BRINGS_WORDS, deployCommands, STRANGER_WORDS } from "../../packs/words.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import { style } from "../term/style.ts";
14 import { truncate, wrap } from "../term/width.ts";
15 import { Scroll } from "./common.ts";
16 import type { Session } from "../session.ts";
17
18 /** One pack as text lines: what it brings, the commands, and the stranger's way in. */
19 export function packLines(p: Pack, origin: string | undefined, width = 80): string[] {
20 const w = Math.max(20, width);
21 return [
22 `${p.title} ${p.version}`,
23 ...wrap(p.description, w),
24 "",
25 "Apps",
26 ...p.apps.map((a) => ` ${a.name} ${a.source}`),
27 ...(p.crew.length === 0 ? [] : ["", "Crew", ...p.crew.map((c) => ` ${c.username} ${c.roles.join(", ")}`)]),
28 "",
29 ...wrap(BRINGS_WORDS, w),
30 "",
31 "Deploy it on your own machine",
32 ...deployCommands(p, origin).map((l) => ` ${l}`),
33 "",
34 "Help build these apps",
35 ...STRANGER_WORDS.flatMap((t) => [...wrap(t, w), ""]).slice(0, -1),
36 ];
37 }
38
39 function packScreen(ctx: Ctx, p: Pack, origin: string | undefined): Screen {
40 const scroll = new Scroll();
41 let lines: string[] = [];
42 return {
43 title: `${p.title} pack`,
44 keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")],
45 render(area, s) {
46 const c = ctx.theme.c;
47 lines = packLines(p, origin, area.w - 4);
48 const from = scroll.window(area.h, lines.length);
49 for (let i = 0; i < area.h; i++) {
50 const line = lines[from + i];
51 if (line !== undefined) s.text(area.x + 1, area.y + i, line, style(c.fg, c.bg), area.x + area.w);
52 }
53 },
54 key(e) {
55 if (e.key === "esc" || e.key === "q") {
56 ctx.pop();
57 return true;
58 }
59 return scroll.key(e, lines.length);
60 },
61 };
62 }
63
64 export function packsScreen(ctx: Ctx, origin: string | undefined): Screen {
65 const { packs } = catalog();
66 let at = 0;
67 return {
68 title: "Forge packs",
69 keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["esc"], "close")],
70 render(area, s) {
71 const c = ctx.theme.c;
72 s.text(area.x + 1, area.y, "Forge packs: a whole forge to start from", style(c.mutedFg, c.bg), area.x + area.w);
73 packs.forEach((p, i) => {
74 const on = i === at;
75 const row = `${on ? "> " : " "}${p.title} v${p.version} ${p.apps.length} apps, ${p.crew.length} crew ${p.description}`;
76 if (2 + i < area.h) s.text(area.x + 1, area.y + 2 + i, truncate(row, area.w - 2), on ? style(c.accent, c.bg) : style(c.fg, c.bg), area.x + area.w);
77 });
78 },
79 key(e) {
80 if (e.ctrl || e.alt) return false;
81 if (e.key === "esc" || e.key === "q") ctx.pop();
82 else if (e.key === "j" || e.key === "down") at = Math.min(packs.length - 1, at + 1);
83 else if (e.key === "k" || e.key === "up") at = Math.max(0, at - 1);
84 else if (e.key === "enter") {
85 const p = packs[at];
86 if (p !== undefined) ctx.push(packScreen(ctx, p, origin));
87 } else return false;
88 return true;
89 },
90 };
91 }
92
93 export const packsCommand = (session: Session, ctx: Ctx) => ({
94 id: "packs",
95 title: "Forge packs",
96 hint: "premade forges to deploy, and how to help build their apps",
97 run: () => ctx.push(packsScreen(ctx, session.app.config.url)),
98 });
src/web/layout.ts +4 −2
@@ -213,7 +213,7 @@ const newIssueItem = (ctx: Ctx): MenuItem[] =>
213213 function guestMenu(ctx: Ctx, theme: ThemeName | undefined): Html {
214214 return menu({
215215 trigger: themeGlyph,
216 items: [...newIssueItem(ctx), helpItem, themeForm(theme)],
216 items: [...newIssueItem(ctx), { label: "Packs", href: "/packs", icon: "rocket" }, helpItem, themeForm(theme)],
217217 label: "Appearance and help",
218218 triggerOptions: { extra: "btn-icon" },
219219 });
@@ -287,6 +287,7 @@ function userMenu(
287287 { label: "Notes", href: "/notes", icon: "comment" },
288288 { label: "Talk", href: "/talk", icon: "comment" },
289289 { label: "Explore", href: "/explore", icon: "search" },
290 { label: "Packs", href: "/packs", icon: "rocket" },
290291 { label: "Map", href: "/map", icon: "map" },
291292 ...featureItems(ctx, user),
292293 { separator: true, label: "" },
@@ -358,6 +359,7 @@ const STATIC_PALETTE: PaletteItem[] = [
358359 { t: "Home", h: "/", c: "Go to" },
359360 { t: "Apps", h: "/apps", c: "Go to" },
360361 { t: "Explore", h: "/explore", c: "Go to" },
362 { t: "Forge packs", h: "/packs", c: "Go to" },
361363 { t: "Map", h: "/map", c: "Go to" },
362364 { t: "What you asked for", h: "/asked", c: "Go to" },
363365 { t: "Front page", h: "/front", c: "Go to" },
@@ -375,7 +377,7 @@ const STATIC_PALETTE: PaletteItem[] = [
375377 ];
376378
377379 /** What a signed-out visitor can reach: places to go, not Create and Settings rows that end at a sign-in wall. */
378 const VISITOR_PALETTE = new Set(["Home", "Apps", "Explore", "Front page", "Search"]);
380 const VISITOR_PALETTE = new Set(["Home", "Apps", "Explore", "Forge packs", "Front page", "Search"]);
379381
380382 /** First path segments that are the forge's own pages, never an owner: no repository read for them. */
381383 const NOT_AN_OWNER = new Set([
src/web/pages/packs.ts added +82
@@ -0,0 +1,82 @@
1 /**
2 * `/packs` and `/packs/:name`: the forge packs this forge ships (`src/packs/`).
3 * A pack's page says what it brings, the one command that deploys it on your
4 * own machine, and how a person with no account can help build its apps and
5 * what happens to their pull. Server-rendered, no script; anyone may read it.
6 */
7
8 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
9 import { catalog, packNamed } from "../../packs/catalog.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { notFoundPage, offersSignup, pageHead } from "../chrome.ts";
12 import { deployCommands, BRINGS_WORDS, STRANGER_WORDS } from "../../packs/words.ts";
13 import { html } from "../html.ts";
14 import { page } from "../layout.ts";
15 import { btn, card, listCard, row } from "../ui.ts";
16
17 const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? "" : "s"}`;
18
19 // GET /packs
20 export function packsPage(ctx: Ctx): Response {
21 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
22 const started = performance.now();
23 const { packs } = catalog();
24 const body = html`${pageHead("Forge packs", {
25 sub: "A whole forge to start from: real apps with their history, and a crew set up to work on them.",
26 })}
27 ${listCard(
28 packs.map((p) =>
29 row({
30 title: html`<a href="/packs/${p.name}">${p.title}</a>`,
31 meta: html`${p.description} ${plural(p.apps.length, "app")}, ${plural(p.crew.length, "crew member")}.`,
32 trail: html`v${p.version}`,
33 }),
34 ),
35 )}`;
36 const response = page({ ctx, title: "Forge packs", body });
37 notePhase(ctx.request, "render", performance.now() - started);
38 return response;
39 }
40
41 // GET /packs/:name
42 export function packPage(ctx: Ctx): Response {
43 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
44 const started = performance.now();
45 const pack = packNamed(ctx.params.name ?? "");
46 if (pack === undefined) return notFoundPage(ctx);
47 const signup = ctx.auth.user === undefined && offersSignup(ctx);
48 const body = html`${pageHead(`${pack.title} pack`, { sub: `${pack.description} Version ${pack.version}.` })}
49 ${card(
50 html`${listCard(
51 pack.apps.map((a) =>
52 row({
53 title: html`<a href="${a.source}" rel="noopener noreferrer">${a.name}</a>`,
54 meta: html`${a.description}`,
55 }),
56 ),
57 )}
58 ${pack.crew.length === 0
59 ? null
60 : html`<h3>The crew it starts with</h3>
61 ${listCard(pack.crew.map((c) => row({ title: c.username, meta: html`${c.roles.join(", ")}. ${c.brief}` })))}`}
62 <p class="muted">${BRINGS_WORDS}</p>`,
63 { head: html`<h2>What it brings</h2>` },
64 )}
65 ${card(
66 html`<div class="stack stack-tight">
67 ${deployCommands(pack, ctx.app.config.url).map((l) => html`<code class="mono break">${l}</code>`)}
68 </div>`,
69 { head: html`<h2>Deploy it on your own machine</h2>` },
70 )}
71 ${card(
72 html`${STRANGER_WORDS.map((w) => html`<p>${w}</p>`)}
73 <p>
74 ${signup ? btn("Get an account", { href: "/signup", variant: "primary" }) : null}
75 ${btn("Start your own forge", { href: "/forges/new" })}
76 </p>`,
77 { head: html`<h2>Help build these apps</h2>` },
78 )}`;
79 const response = page({ ctx, title: `${pack.title} pack`, body });
80 notePhase(ctx.request, "render", performance.now() - started);
81 return response;
82 }
test/forge-looks.test.ts +1
@@ -26,6 +26,7 @@ const NOT_A_PAGE: Record<string, string> = {
2626 "/family": "no picture: the cap of 24 looks went to the preview's /waves and /milestones; the page is covered by its own tests",
2727 "/llms.txt": "plain markdown for machines, not a page to picture",
2828 "/agents": "/llms.txt drawn as a page for an assistant; no picture: the cap of 24 looks is spent, and test/agents-page.test.ts covers it",
29 "/packs": "no picture: the cap of 24 looks is spent; the page is covered by test/packs.test.ts",
2930 "/robots.txt": "plain text for crawlers",
3031 "/openapi.json": "a redirect to the contract document",
3132 "/favicon.ico": "the tab icon, an image",
test/nightly-output.test.ts +1 −1
@@ -89,7 +89,7 @@ describe("a nightly job's output", () => {
8989
9090 test("only the hunt script's steps are read for what they filed, never install", () => {
9191 for (const job of JOBS)
92 for (const step of job.steps) expect([step.label, readsFiled(step)]).toEqual([step.label, step.label !== "install"]);
92 for (const step of job.steps) expect([step.label, readsFiled(step)]).toEqual([step.label, step.command.startsWith("bun scripts/flake-hunt.ts ")]);
9393 });
9494
9595 test("the record says what the run filed, or that it filed nothing", () => {
test/packs-hands.test.ts added +125
@@ -0,0 +1,125 @@
1 /** The real hands of a pack deploy: no credential leaves for a stranger's host, 409 alone means "exists", the terminal screen. */
2 import { afterAll, beforeAll, describe, 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 { createApp, type App } from "../src/app.ts";
7 import { createUser } from "../src/core/users.ts";
8 import { serve } from "../src/http/server.ts";
9 import { packNamed } from "../src/packs/catalog.ts";
10 import { deployCommands } from "../src/packs/words.ts";
11 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
12 import { packLines, packsScreen } from "../src/tui/screens/packs.ts";
13 import { headlessTerminal } from "../src/tui/term/terminal.ts";
14 import { cloneEnv, pushEnv, realHands } from "../scripts/pack.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 describe("git credentials", () => {
21 test("the clone env carries no Authorization and no extra header", () => {
22 const env = cloneEnv();
23 expect(Object.entries(env).some(([k, v]) => /authorization|extraheader/i.test(k + v))).toBe(false);
24 expect(env.GIT_CONFIG_COUNT).toBeUndefined();
25 });
26 test("the push env scopes the header to the forge's own address", () => {
27 const env = pushEnv("https://forge.example/", "tok");
28 expect(env.GIT_CONFIG_KEY_0).toBe("http.https://forge.example/.extraHeader");
29 expect(env.GIT_CONFIG_VALUE_0).toContain("Authorization: Basic ");
30 });
31 test("cloning a source sends the source host nothing but git's own asks", async () => {
32 const seen: (string | null)[] = [];
33 const stranger = Bun.serve({
34 port: 0,
35 fetch(req) {
36 seen.push(req.headers.get("authorization"));
37 return new Response("no", { status: 404 });
38 },
39 });
40 try {
41 const r = await realHands("http://127.0.0.1:1", "secret-token").carryHistory(`http://127.0.0.1:${stranger.port}/x.git`, "o", "n", undefined);
42 expect(r.ok).toBe(false);
43 expect(seen.length).toBeGreaterThan(0);
44 expect(seen.every((h) => h === null)).toBe(true);
45 } finally {
46 await stranger.stop(true);
47 }
48 });
49 });
50
51 describe("makeAgent", () => {
52 const answering = (status: number) => Bun.serve({ port: 0, fetch: () => new Response("{}", { status }) });
53 const member = { username: "helper", roles: ["builder" as const], brief: "Help." };
54 test("only 409 means the crew member exists", async () => {
55 for (const [status, exists] of [[409, true], [422, false], [500, false]] as const) {
56 const s = answering(status);
57 try {
58 const r = await realHands(`http://127.0.0.1:${s.port}`, "t").makeAgent(member);
59 expect(r.ok).toBe(false);
60 if (!r.ok) expect([status, r.exists]).toEqual([status, exists]);
61 } finally {
62 await s.stop(true);
63 }
64 }
65 });
66 });
67
68 describe("deploy words", () => {
69 const games = packNamed("games")!;
70 test("the clone source is the configured address, never a request's Host", () => {
71 expect(deployCommands(games, "https://f.example/")[0]).toContain("git clone https://f.example/trevato/forge.git");
72 expect(deployCommands(games, undefined)[0]).not.toContain("http");
73 });
74 test("the web page does not echo a forged Host", async () => {
75 const dir = await mkdtemp(join(tmpdir(), "packs-host-"));
76 const a = createApp({ dataDir: dir, port: 0 });
77 const servers = await serve(a);
78 try {
79 const res = await fetch(`${servers.url}/packs/games`, { headers: { host: "evil.example" } });
80 expect(res.status).toBe(200);
81 expect(await res.text()).not.toContain("git clone http://evil.example");
82 } finally {
83 await servers.stop();
84 a.close();
85 await rm(dir, { recursive: true, force: true });
86 }
87 });
88 });
89
90 describe("terminal screen", () => {
91 let dir: string;
92 let app: App;
93 beforeAll(async () => {
94 dir = await mkdtemp(join(tmpdir(), "packs-tui-"));
95 app = createApp({ dataDir: dir, port: 0 });
96 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
97 if (!r.ok) throw new Error(r.error);
98 });
99 afterAll(async () => {
100 app.close();
101 await rm(dir, { recursive: true, force: true });
102 });
103 const open = () => createForgeApp(app, { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t" }, { terminal: headlessTerminal(120, 40), exitHooks: false });
104
105 test("a stranger is offered it in the palette", () => {
106 const { app: tui, session } = open();
107 expect(forgeCommands(session)(tui.ctx).map((c) => c.id)).toContain("packs");
108 tui.stop?.();
109 });
110 test("it lists the packs, opens one, and says the deploy command and what happens to a pull", async () => {
111 const { app: tui } = open();
112 tui.start();
113 tui.ctx.push(packsScreen(tui.ctx, undefined));
114 await settleApp(tui);
115 const list = tui.screen.toText();
116 expect(list).toContain("Games");
117 expect(list).toContain("Business");
118 tui.ctx.pop();
119 const lines = packLines(packNamed("games")!, undefined).join("\n");
120 expect(lines).toContain("scripts/pack.ts deploy games");
121 expect(lines).toContain("waits for a person");
122 expect(lines).toContain("2048");
123 tui.stop?.();
124 });
125 });
test/packs.test.ts added +112
@@ -0,0 +1,112 @@
1 /** Packs: the definition is checked, the shipped two are sound, the deploy keeps its order, and the pages answer. */
2 import { afterAll, beforeAll, describe, 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 { createApp, type App } from "../src/app.ts";
7 import { serve, type Servers } from "../src/http/server.ts";
8 import { catalog } from "../src/packs/catalog.ts";
9 import { deployPack, type Hands } from "../src/packs/deploy.ts";
10 import { parsePack, sourceOf } from "../src/packs/pack.ts";
11
12 const good = {
13 pack: 1,
14 name: "mini",
15 title: "Mini",
16 version: "1.0.0",
17 description: "One app.",
18 apps: [{ name: "one", description: "An app.", source: "https://github.com/a/b" }],
19 crew: [{ username: "helper", roles: ["builder"], brief: "Help." }],
20 };
21 const parse = (o: unknown) => parsePack(JSON.stringify(o));
22
23 describe("pack definition", () => {
24 test("a good pack parses", () => {
25 const r = parse(good);
26 expect(r.ok).toBe(true);
27 });
28 test("a pack brings no data, secrets or variables, at any depth", () => {
29 for (const extra of [{ secrets: {} }, { env: {} }, { apps: [{ ...good.apps[0], variables: [] }] }, { crew: [{ ...good.crew[0], token: "x" }] }]) {
30 const r = parse({ ...good, ...extra });
31 expect(r.ok).toBe(false);
32 if (!r.ok) expect(r.error).toContain("not allowed");
33 }
34 });
35 test("sources are public https repositories without credentials", () => {
36 expect(sourceOf("https://github.com/a/b.git")).toBe("https://github.com/a/b");
37 for (const bad of ["http://github.com/a/b", "https://u:[email protected]/a/b", "[email protected]:a/b", "https://github.com/a", "https://github.com/a/b?x=1", "file:///etc"])
38 expect(sourceOf(bad)).toBeUndefined();
39 });
40 test("each field is checked", () => {
41 expect(parse({ ...good, pack: 2 }).ok).toBe(false);
42 expect(parse({ ...good, name: "Bad Name" }).ok).toBe(false);
43 expect(parse({ ...good, version: "1" }).ok).toBe(false);
44 expect(parse({ ...good, apps: [] }).ok).toBe(false);
45 expect(parse({ ...good, crew: [{ ...good.crew[0], roles: ["admin"] }] }).ok).toBe(false);
46 expect(parsePack("nope").ok).toBe(false);
47 });
48 });
49
50 describe("shipped packs", () => {
51 test("Games and Business ship and parse", () => {
52 const c = catalog();
53 expect(c.problems).toEqual([]);
54 expect(c.packs.map((p) => p.name)).toEqual(["business", "games"]);
55 for (const p of c.packs) expect(p.apps.length).toBeGreaterThan(0);
56 });
57 });
58
59 describe("deploy", () => {
60 test("makes apps, carries history, adds crew, keeps what exists and reports failures", async () => {
61 const calls: string[] = [];
62 const hands: Hands = {
63 async makeApp(name) {
64 calls.push(`app ${name}`);
65 return name === "one" ? { ok: true, owner: "me" } : { ok: false, exists: true, why: "x" };
66 },
67 async carryHistory(source, owner, name) {
68 calls.push(`history ${source} -> ${owner}/${name}`);
69 return { ok: true };
70 },
71 async makeAgent(a) {
72 calls.push(`agent ${a.username}`);
73 return { ok: false, exists: false, why: "no" };
74 },
75 };
76 const pack = parse({ ...good, apps: [...good.apps, { name: "two", description: "d", source: "https://github.com/c/d" }] });
77 if (!pack.ok) throw new Error(pack.error);
78 const steps = await deployPack(pack.value, hands);
79 expect(calls).toEqual(["app one", "history https://github.com/a/b -> me/one", "app two", "agent helper"]);
80 expect(steps.map((s) => s.state)).toEqual(["done", "kept", "failed"]);
81 });
82 });
83
84 describe("pages", () => {
85 let dataDir: string;
86 let app: App;
87 let servers: Servers;
88 beforeAll(async () => {
89 dataDir = await mkdtemp(join(tmpdir(), "forge-packs-"));
90 app = createApp({ dataDir, port: 0 });
91 servers = await serve(app);
92 });
93 afterAll(async () => {
94 await servers.stop();
95 app.close();
96 await rm(dataDir, { recursive: true, force: true });
97 });
98 test("a stranger reads the list and a pack's page, with the deploy command and what happens to a pull", async () => {
99 const list = await fetch(`${servers.url}/packs`);
100 expect(list.status).toBe(200);
101 const text = await list.text();
102 expect(text).toContain("/packs/games");
103 expect(text).toContain("/packs/business");
104 const one = await fetch(`${servers.url}/packs/games`);
105 expect(one.status).toBe(200);
106 const body = await one.text();
107 expect(body).toContain("scripts/pack.ts deploy games");
108 expect(body).toContain("waits for a person");
109 expect(body).toContain("gabrielecirulli/2048");
110 expect((await fetch(`${servers.url}/packs/nothing`)).status).toBe(404);
111 });
112 });
test/palette-repos.test.ts +1 −1
@@ -114,7 +114,7 @@ test("a visitor's palette holds places to go and Sign in; a signed-in one holds
114114 return (JSON.parse(m[1]!) as { items: { t: string; h: string; c?: string }[] }).items.filter((i) => i.c !== "Repositories");
115115 };
116116 const out = await rows({}, "/explore?q=a%26b");
117 expect(out.map((i) => i.t)).toEqual(["Home", "Apps", "Explore", "Front page", "Search", "Sign in"]);
117 expect(out.map((i) => i.t)).toEqual(["Home", "Apps", "Explore", "Forge packs", "Front page", "Search", "Sign in"]);
118118 expect(out.find((i) => i.t === "Sign in")!.h).toBe("/signin");
119119 const inn = (await rows({ cookie }, "/explore")).map((i) => i.t);
120120 for (const t of ["New app", "New repository", "Migrate a repository", "New organization", "What you asked for", "Account settings", "Access tokens", "SSH keys", "Origins", "Map"]) expect(inn).toContain(t);
test/palette-roster.test.ts +4
@@ -47,6 +47,7 @@ test("a site-admin person is offered the whole roster", () => {
4747 expect(roster(admin)).toEqual([
4848 ["home", "Home", "your repositories"],
4949 ["explore", "Explore", "on the web"],
50 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
5051 ["profile", "Open a profile", "username or org"],
5152 ["repos", "Repositories", "the repositories you may write"],
5253 ["new-issue-anywhere", "New issue", "pick a repository"],
@@ -148,6 +149,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
148149 expect(roster({ ...admin, kind: "agent" })).toEqual([
149150 ["home", "Home", "your repositories"],
150151 ["explore", "Explore", "on the web"],
152 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
151153 ["profile", "Open a profile", "username or org"],
152154 ["repos", "Repositories", "the repositories you may write"],
153155 ["new-issue-anywhere", "New issue", "pick a repository"],
@@ -213,6 +215,7 @@ test("a member person is offered the signed-in roster without the admin entries"
213215 expect(roster(member)).toEqual([
214216 ["home", "Home", "your repositories"],
215217 ["explore", "Explore", "on the web"],
218 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
216219 ["profile", "Open a profile", "username or org"],
217220 ["repos", "Repositories", "the repositories you may write"],
218221 ["new-issue-anywhere", "New issue", "pick a repository"],
@@ -275,6 +278,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
275278 expect(roster()).toEqual([
276279 ["home", "Home", "your repositories"],
277280 ["explore", "Explore", "on the web"],
281 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
278282 ["profile", "Open a profile", "username or org"],
279283 ["repo", "Open a repository", "owner/name"],
280284 ["issues", "Issues in a repository", "owner/name"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs