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

/packs reads pack repos the owner marks, and packs/ leaves the forge's source tree #10627

Merged opened this

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

Merged

mason/forge:mason/10532-packs-reads-pack-repos-the-owner-marks-a → main 4 commits fork 5 threads · 5 unresolved

  • Merged. mason/10532-packs-reads-pack-repos-the-owner-marks-a was merged into main as 3d892b0.

Review

Sign in to review this change.
34 files changed +831 −72
Dockerfile −2
@@ -23,8 +23,6 @@ COPY docs/PRINCIPLES.md ./docs/PRINCIPLES.md
2323 COPY docs/DESIGN.md ./docs/DESIGN.md
2424 # The OpenCode plugin the forge serves at /plugins/opencode/forge.ts (src/http/plugin_routes.ts).
2525 COPY plugins/opencode ./plugins/opencode
26 # The forge packs /packs lists and deploys (src/packs/catalog.ts reads packs/ at the root).
27 COPY packs ./packs
2826 # src/ alone: nothing under it imports scripts/ or test/ (test/src-imports.test.ts),
2927 # and the boot proof below fails the build if that ever stops being so (#2652).
3028
docs/design/attention-budget.json +2 −2
@@ -109,12 +109,12 @@
109109 },
110110 "settings": {
111111 "390": {
112 "words": 137,
112 "words": 138,
113113 "blocks": 32,
114114 "colours": 1
115115 },
116116 "1280": {
117 "words": 137,
117 "words": 138,
118118 "blocks": 32,
119119 "colours": 1
120120 }
docs/design/audit.md +1
@@ -115,6 +115,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
115115 - `/settings/backups` — `pages/backups.ts` (621 lines) · backups and restore · `/settings/resilience`, `/releases` · none seen.
116116 - `/settings/flags` — `pages/flags.ts` · the forge's switches · none · none seen.
117117 - `/settings/features` — `pages/features.ts` · apps absorbed as the forge's features, and their settings · `/apps` (the same apps, as apps) · none seen.
118 - `/settings/packs` — `pages/packs-settings.ts` · the repositories /packs lists, one textarea and Save · `/packs`, `/settings/features` · none seen.
118119 - `/settings/model` — `pages/model.ts` · which model the crew runs on · `/settings/crew` · none seen.
119120 - `/settings/cloud` — `pages/cloud.ts` · cloud provider connection · none · none seen.
120121 - `/settings/cloud/audit` — `pages/cloud.ts` · what the cloud connection did · `/audit` (different subject, same word) · none seen.
docs/features/packs.md +6 −2
@@ -2,7 +2,11 @@
22
33 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 a `source` that is either an https address of a public git repository or `owner/name` of a repository on a forge, 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.
44
5 Two ship in `packs/`: **games** (2048, hextris, react-tetris) and **business** (memos, kanboard), each with a crew.
5 A pack is a public forge repo with a `pack.json` at its root, named like the pack (`packs/games`, `packs/business`); its apps may name forge repos (`packs/2048`). Nothing ships in the source tree or the image: an admin lists the repos /packs shows in **Settings › Packs** (the `packs.repos` setting, kept in `forge_setting_change`), and `src/packs/catalog.ts` reads each one's `pack.json` from git at its default branch. A repo not listed is not a pack; a listed one that is missing, private, without a valid pack.json or named otherwise is left out and named on that page. The catalog is read at boot, on save and by /packs, so a pushed change shows within seconds. The fixtures the tests use are in `test/fixtures/packs/`.
6
7 - **Marking a pack, three doors, one rule** (`src/packs/set.ts`: admin only, validated, kept in `forge_setting_change`, then the catalog is re-read, so the change is on /packs at once): Settings › Packs; the **Pack repositories** screen in the terminal palette (admins only, `src/tui/screens/packrepos.ts`); and `repo_settings` with `{ packRepos: ["packs/games", …] }` over MCP, which answers `{ repos, problems }`. Reads of git are single-flight (`refreshCatalog`): one at a time, so an older read never overwrites a newer one; a read that fails is logged and the catalog stays as it was, at boot and on every page.
8 - **Setting up a forge's packs.** A fresh forge lists no pack. For each pack, create a public repository named like the pack (any owner you can push as; not under `packs/`, which is read-only to everyone but the mirror), push the `pack.json` to its default branch (`test/fixtures/packs/games.json` and `business.json` are the two the forge used to ship), then mark `owner/name` in Settings › Packs. `bun scripts/pack.ts up <name>` reads `packs/<name>` on `FORGE_PACKS_FROM` (default the public forge) at that repository's default branch (`raw/HEAD`).
9 - **Why the fixture packs still name https upstreams.** An app's `source` is the project's real home on purpose: `up` clones and follows it, and the mirror (below) turns each https source into `packs/<app>` on a forge that wants a local copy. Writing `packs/2048` into the shipped data would make a deploy depend on that forge having mirrored first. A pack that wants forge sources may use them (see below).
610
711 - **Deploy one, one command.** After `git clone … && cd forge && bun install`, `bun scripts/pack.ts up games` makes the owner account (its passphrase is shown once and kept 0600 in `forge-data/owner-passphrase`; `FORGE_DATA` and `PORT` move the data and the port), boots this tree's forge on http://localhost:3000, makes each app, pushes the source's whole history (branches and tags), turns on following the source (Settings shows it; a new upstream commit shows as drift at the next check), adds the crew, and stays up serving. Nothing is asked but the model credential: with none in the environment it says the crew is off until one is added in Settings › Subscriptions. Run it again to finish a partial deploy: what exists is kept (history is never pushed again), and an app that was made but never followed is followed now; with no branch from the pack or the clone, that step fails rather than guess one. The carried branch becomes the app's default branch. Whether the crew has a credential is read from the data directory (Settings › Subscriptions) as well as the environment. Against a forge you already run: `FORGE_URL=… FORGE_TOKEN=forge_… bun scripts/pack.ts deploy games`.
812 - **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`). The home page (`/`) has one press for each pack in its "Get your own forge" section, a link to `/packs/<name>` with an empty `data-slot="host"` beside it for a later hosted deploy; the pack page shows the commands in one `copyField` block (`#pack-command`, a read-only text area you select with a click; its copy button is `js-only`, so it is on the page only when script runs). 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 (the `up` line included: the web page and the screen both take them from `deployCommands`) and what happens to a stranger's pull. The words are shared in `src/packs/words.ts`.
@@ -14,6 +18,6 @@ Not done yet: a hosted "deploy this pack" press, a stable follow link from each
1418
1519 ## The upstream mirrors (#10530)
1620
17 An admin brings each https `source` in `packs/*/pack.json` in once as a public repository `packs/<app>` (owner `packs`, an account nobody signs in as), from any of three doors: the "Mirror the upstreams" button on `/packs`, the "Mirror the packs' upstreams" screen in the terminal palette (admins only), or `repo_settings` with `{ mirrorPacks: true }` over MCP, which answers `{ made, kept, failed, says }`. The request only reads each upstream's default branch, creates the repository and its follow, and stops at 20 seconds in all: what it did not reach is listed as failed ("press again"). The first sync is the scheduled follow's, as is every later one; it moves the default branch to the upstream's tip instead of opening a pull, and a failed sync is noted on the follow's row. Running it again leaves existing mirrors as they are.
21 An admin brings each https `source` in a marked pack's `pack.json` in once as a public repository `packs/<app>` (owner `packs`, an account nobody signs in as), from any of three doors: the "Mirror the upstreams" button on `/packs`, the "Mirror the packs' upstreams" screen in the terminal palette (admins only), or `repo_settings` with `{ mirrorPacks: true }` over MCP, which answers `{ made, kept, failed, says }`. The request only reads each upstream's default branch, creates the repository and its follow, and stops at 20 seconds in all: what it did not reach is listed as failed ("press again"). The first sync is the scheduled follow's, as is every later one; it moves the default branch to the upstream's tip instead of opening a pull, and a failed sync is noted on the follow's row. Running it again leaves existing mirrors as they are.
1822
1923 Read-only is enforced where writes are decided: `canWrite` is false for every `packs/*` repository except for its owner, which only the follow acts as, so pushes (http and ssh, told why), web file edits, the contents and branch API, and opening or merging a pull are all refused, admins included. Open on purpose: issues and comments, and a repository admin's settings (visibility, delete, transfer). A mirror tracks the branch that was the upstream's HEAD when it was made; if the upstream renames its default branch, delete the mirror and mirror again.
scripts/pack-compat.ts +13 −5
@@ -14,13 +14,21 @@ import { createApp } from "../src/app.ts";
1414 import { createToken } from "../src/core/tokens.ts";
1515 import { createUser } from "../src/core/users.ts";
1616 import { serve } from "../src/http/server.ts";
17 import { catalog } from "../src/packs/catalog.ts";
1817 import { deployPack } from "../src/packs/deploy.ts";
19 import { realHands } from "./pack.ts";
18 import { fetchPack, PACKS_FORGE, realHands } from "./pack.ts";
19 import type { Pack } from "../src/packs/pack.ts";
2020
21 const { packs, problems } = catalog();
22 let failed = problems.length;
23 for (const p of problems) console.error(`✗ ${p}`);
21 // The packs are forge repos (`packs/<name>`); the names come from the command line, else the two the forge starts with.
22 const names = process.argv.slice(2).length > 0 ? process.argv.slice(2) : ["games", "business"];
23 const packs: Pack[] = [];
24 let failed = 0;
25 for (const n of names) {
26 const got = await fetchPack(n, process.env.FORGE_PACKS_FROM ?? PACKS_FORGE);
27 if (typeof got === "string") {
28 failed++;
29 console.error(`✗ ${got}`);
30 } else packs.push(got);
31 }
2432
2533 for (const pack of packs) {
2634 const dataDir = await mkdtemp(join(tmpdir(), `pack-compat-${pack.name}-`));
scripts/pack.ts +20 −5
@@ -5,7 +5,7 @@
55 * bun scripts/pack.ts show games
66 * FORGE_URL=http://localhost:3000 FORGE_TOKEN=forge_… bun scripts/pack.ts deploy games
77 *
8 * `<pack>` is a shipped pack's name, or the path of a pack.json. `deploy` makes
8 * `<pack>` is the name of a pack repo (`packs/<name>` on FORGE_PACKS_FROM, default the public forge), or the path of a pack.json. `deploy` makes
99 * each app in the forge at FORGE_URL (the token's owner owns them), copies the
1010 * source's whole history in, and adds the crew. It brings no data, secret or
1111 * variable, and leaves the model credential to you. Run it again to finish a
@@ -16,13 +16,28 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
1616 import { tmpdir } from "node:os";
1717 import { join } from "node:path";
1818 import { ForgeApi } from "../src/cli/api.ts";
19 import { packNamed } from "../src/packs/catalog.ts";
2019 import { deployPack, type Hands } from "../src/packs/deploy.ts";
2120 import { parsePack, type Pack } from "../src/packs/pack.ts";
2221 import { finishWords, OWNER, prepareOwner } from "../src/packs/up.ts";
2322
24 function load(arg: string): Pack | string {
25 if (/^[a-z][a-z0-9-]*$/.test(arg) && !existsSync(arg)) return packNamed(arg) ?? `no pack named ${arg}`;
23 /** Where a pack's name is looked up: the forge whose `packs/<name>` repository holds its pack.json. */
24 export const PACKS_FORGE = "https://forge.open-platform.sh";
25
26 /** The pack `<name>` as published at `packs/<name>` on `origin` (its default branch, whatever it is called), or why not. */
27 export async function fetchPack(name: string, origin: string, get: typeof fetch = fetch): Promise<Pack | string> {
28 const url = `${origin.replace(/\/+$/, "")}/packs/${name}/raw/HEAD/pack.json`;
29 try {
30 const res = await get(url, { redirect: "error", signal: AbortSignal.timeout(15_000) });
31 if (res.status !== 200) return `no pack named ${name} at ${url} (${res.status})`;
32 const parsed = parsePack(await res.text());
33 return parsed.ok ? parsed.value : `${url}: ${parsed.error}`;
34 } catch {
35 return `could not read ${url}`;
36 }
37 }
38
39 async function load(arg: string): Promise<Pack | string> {
40 if (/^[a-z][a-z0-9-]*$/.test(arg) && !existsSync(arg)) return fetchPack(arg, process.env.FORGE_PACKS_FROM ?? PACKS_FORGE);
2641 if (!existsSync(arg)) return `no such file: ${arg}`;
2742 const parsed = parsePack(readFileSync(arg, "utf8"));
2843 return parsed.ok ? parsed.value : parsed.error;
@@ -228,7 +243,7 @@ export async function up(pack: Pack, options: UpOptions): Promise<number> {
228243
229244 if (import.meta.main) {
230245 const [verb, which] = process.argv.slice(2);
231 const pack = which === undefined ? USAGE : load(which);
246 const pack = which === undefined ? USAGE : await load(which);
232247 if (typeof pack === "string" || (verb !== "show" && verb !== "deploy" && verb !== "up")) {
233248 console.error(typeof pack === "string" ? pack : USAGE);
234249 process.exit(2);
src/http/mcp/tools/repo_settings.ts +12 −1
@@ -1,12 +1,14 @@
11 import { updateRepoSettings } from "../../../actions/repos.ts";
22 import { TRANSFER_SAID, transferRepository } from "../../../actions/transfer.ts";
33 import { mirrorPacks, reportWords } from "../../../packs/mirror.ts";
4 import { rejectedMarkedSentence } from "../../../packs/marked.ts";
5 import { setPackRepos } from "../../../packs/set.ts";
46 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
57 import { VISIBILITY_WORDS } from "../../../core/arg_words.ts";
68
79 export const tool: Tool = {
810 name: "repo_settings",
9 doc: "`{ mirrorPacks: true }` (admin) brings each https source in packs/*/pack.json in once as a read-only `packs/<app>` that follows its upstream, and answers `{ made, kept, failed, says }`; running it again makes nothing new. `{ repo, description?, visibility?, newOwner? }` (admin): `newOwner` moves the repository to that account or org as `POST /api/v1/repos/:owner/:name/transfer` does, with its codes and sentences (`name_taken`, `same_owner`, `move_failed`, `forbidden_owner`, `unknown_owner`) and answers `{ repo, description, visibility }` at the new path, and cannot be combined with the others; otherwise change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }` (with none to change it only reads); a visibility other than public or private is `invalid`",
11 doc: "`{ packRepos: [\"packs/games\", …] }` (admin) sets the repositories /packs lists, as Settings › Packs does (public repos with a pack.json at the root; at most 20; an empty list lists none), and answers `{ repos, problems }` with the ones left out and why; it shows on /packs at once; a word that is not `owner/name` is `invalid`. `{ mirrorPacks: true }` (admin) brings each https source in a marked pack repo's pack.json in once as a read-only `packs/<app>` that follows its upstream, and answers `{ made, kept, failed, says }`; running it again makes nothing new. `{ repo, description?, visibility?, newOwner? }` (admin): `newOwner` moves the repository to that account or org as `POST /api/v1/repos/:owner/:name/transfer` does, with its codes and sentences (`name_taken`, `same_owner`, `move_failed`, `forbidden_owner`, `unknown_owner`) and answers `{ repo, description, visibility }` at the new path, and cannot be combined with the others; otherwise change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }` (with none to change it only reads); a visibility other than public or private is `invalid`",
1012 description:
1113 "Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams.",
1214 inputSchema: {
@@ -17,11 +19,20 @@ export const tool: Tool = {
1719 visibility: { type: "string", enum: ["public", "private"] },
1820 newOwner: { type: "string", maxLength: 64 },
1921 mirrorPacks: { type: "boolean" },
22 packRepos: { type: "array", items: { type: "string", maxLength: 100 }, maxItems: 20 },
2023 },
2124 },
2225 writes: true,
2326 needsAdminScope: true,
2427 async run(ctx, user, args) {
28 const packRepos = args["packRepos"];
29 if (packRepos !== undefined) {
30 if (!Array.isArray(packRepos) || packRepos.length > 20 || packRepos.some((v) => typeof v !== "string"))
31 return bad("invalid", "packRepos is a list of owner/name words");
32 const done = await setPackRepos(ctx.app, user, packRepos.join(" "));
33 if (done.ok) return good({ repos: done.repos, problems: done.problems });
34 return done.error === "forbidden" ? bad("not_found", "no such tool here") : bad("invalid", rejectedMarkedSentence(done.rejected));
35 }
2536 if (args["mirrorPacks"] === true) {
2637 const done = await mirrorPacks(ctx.app, user);
2738 return done.ok ? good({ ...done.value, says: reportWords(done.value) }) : bad("not_found", "no such tool here");
src/http/settings_routes.ts +3
@@ -34,6 +34,7 @@ import {
3434 signInAct,
3535 } from "../web/pages/accounts.ts";
3636 import { confirmsList, confirmYes } from "../web/pages/confirms.ts";
37 import { packsSettingsPage, packsSettingsSave } from "../web/pages/packs-settings.ts";
3738 import { featureAccept, featurePage, featureRun, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts";
3839 import { passkeyAdd, passkeyDelete, passkeyOptions, passkeysPage } from "../web/pages/passkeys.ts";
3940 import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts";
@@ -187,6 +188,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
187188 router.add("POST", "/settings/economics", economicsSet);
188189 router.add("GET", "/settings/flags", flagsPage);
189190 // Apps absorbed as the forge's own features, and their settings.
191 router.add("GET", "/settings/packs", packsSettingsPage);
192 router.add("POST", "/settings/packs", packsSettingsSave);
190193 router.add("GET", "/settings/features", featuresPage);
191194 router.add("GET", "/settings/features/:slug", featurePage);
192195 router.add("POST", "/settings/features/:slug", featureSave);
src/main.ts +2
@@ -20,6 +20,7 @@ import { createApp } from "./app.ts";
2020 import { readOnlyFrom } from "./http/read_only.ts";
2121 import { warmFlow } from "./system/flow_cache.ts";
2222 import { backfillCarried } from "./core/carried.ts";
23 import { refreshCatalogQuietly } from "./packs/catalog.ts";
2324 import { loadTrustedHosts } from "./origins/trusted-hosts.ts";
2425 import { flushFlagUses, loadFlags } from "./core/flags.ts";
2526 import { reconcile } from "./core/reconcile.ts";
@@ -330,6 +331,7 @@ const app = createApp({
330331 timeStatements(app.db, createLog("sql"));
331332 loadFlags(app.db);
332333 loadTrustedHosts(app.db);
334 await refreshCatalogQuietly(app);
outdated

Boot now waits on a git read of every marked repo, and a throw here stops the forge. Wrap it in try/catch and log, or start it without awaiting and with a catch.

333335 if (readOnly) {
334336 const servers = await serve(app);
335337 // Only the listener runs: no reconcile, perf watcher, timers, apps, crew,
src/packs/catalog.ts +89 −20
@@ -1,34 +1,103 @@
11 /**
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.
2 * The packs this forge shows (#10532): a pack is a forge repo with a
3 * `pack.json` at its root, and the packs are the public repos an admin marked
4 * in the `packs.repos` setting (`marked.ts`), read from git at their default
5 * branch. A repo not marked is not listed. A repo that is missing, private,
6 * has no pack.json or one that fails to parse is left out and named in
7 * `problems`, so a bad pack never reaches a page.
8 *
9 * `catalog()` answers from memory; `refreshCatalog(app)` re-reads git. It is
10 * called at boot, when the setting is saved, and by the pages that list packs.
511 */
612
7 import { existsSync, readdirSync, readFileSync } from "node:fs";
8 import { join } from "node:path";
13 import type { App } from "../app.ts";
14 import { getRepo } from "../core/repos.ts";
15 import { blobHead } from "../git/read.ts";
16 import { repoDir } from "../git/repos.ts";
17 import { markedRepos } from "./marked.ts";
918 import { parsePack, type Pack } from "./pack.ts";
1019
11 const ROOT = join(import.meta.dir, "..", "..", "packs");
20 export type Catalog = { packs: readonly Pack[]; problems: readonly string[] };
1221
13 export function loadCatalog(root = ROOT): { packs: readonly Pack[]; problems: readonly string[] } {
22 /** A pack.json larger than this is not read. */
23 export const MAX_PACK_BYTES = 64 * 1024;
24
25 /** Read the marked repos of `app` from git. */
26 export async function readCatalog(app: App): Promise<Catalog> {
1427 const packs: Pack[] = [];
1528 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);
29 for (const full of markedRepos(app.db)) {
30 const [owner = "", name = ""] = full.split("/");
31 const repo = getRepo(app.db, owner, name);
32 if (repo === undefined) problems.push(`${full}: no such repository`);
33 else if (repo.visibility !== "public") problems.push(`${full}: a pack repository must be public`);
34 else {
35 const bytes = await blobHead(repoDir(app.reposRoot, repo.ownerName, repo.name), "HEAD", "pack.json", MAX_PACK_BYTES + 1);
36 if (bytes === undefined || bytes.length === 0) problems.push(`${full}: no pack.json at its root`);
37 else if (bytes.length > MAX_PACK_BYTES) problems.push(`${full}: pack.json is too large`);
38 else {
39 const parsed = parsePack(new TextDecoder().decode(bytes));
40 if (!parsed.ok) problems.push(`${full}: ${parsed.error}`);
41 else if (parsed.value.name !== repo.name.toLowerCase()) problems.push(`${full}: pack name must match the repository name`);
42 else if (!packs.some((p) => p.name === parsed.value.name)) packs.push(parsed.value);
43 else problems.push(`${full}: another marked repository is already the pack ${parsed.value.name}`);
44 }
45 }
2546 }
2647 packs.sort((a, b) => a.name.localeCompare(b.name));
2748 return { packs, problems };
2849 }
2950
30 let cached: ReturnType<typeof loadCatalog> | undefined;
31 /** The shipped catalog, read once per process. */
32 export const catalog = (): ReturnType<typeof loadCatalog> => (cached ??= loadCatalog());
51 let current: Catalog = { packs: [], problems: [] };
52 const readAt = new WeakMap<App, number>();
53 let running: Promise<Catalog> | undefined;
54 let queued: Promise<Catalog> | undefined;
3355
34 export const packNamed = (name: string): Pack | undefined => catalog().packs.find((p) => p.name === name);
56 /** The catalog as last read, in memory. */
57 export const catalog = (): Catalog => current;
58
59 export const packNamed = (name: string): Pack | undefined => current.packs.find((p) => p.name === name);
60
61 async function readOnce(app: App): Promise<Catalog> {
62 const read = await readCatalog(app);
63 current = read;
64 readAt.set(app, Date.now());
65 return read;
66 }
67
68 /**
69 * Re-read git unless this app's catalog was read within `maxAgeMs`. Reads run
70 * one at a time and in the order asked: a call made while one is running waits
71 * for it and then reads again (so it sees what changed since that one began),
72 * and every call made meanwhile shares that second read. An older read can
73 * therefore never overwrite a newer one.
74 */
75 export function refreshCatalog(app: App, maxAgeMs = 0): Promise<Catalog> {
76 const last = readAt.get(app);
77 if (last !== undefined && Date.now() - last < maxAgeMs) return Promise.resolve(current);
78 if (running === undefined) {
79 const mine = readOnce(app).finally(() => {
80 if (running === mine) running = undefined;
81 });
82 running = mine;
83 return mine;
84 }
85 if (queued === undefined) {
86 const next: Promise<Catalog> = running
87 .catch(() => undefined)
88 .then(() => {
89 queued = undefined;
90 return refreshCatalog(app);
91 });
92 queued = next;
93 }
94 return queued;
95 }
96
97 /** `refreshCatalog` for a caller that cannot act on a failure: it is logged, the catalog stays as it was. */
98 export function refreshCatalogQuietly(app: App, maxAgeMs = 0): Promise<Catalog> {
99 return refreshCatalog(app, maxAgeMs).catch((e: unknown) => {
100 app.log.warn("the packs catalog could not be read", { error: e instanceof Error ? e.message : String(e) });
101 return current;
102 });
103 }
src/packs/marked.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * The repos an admin has marked as packs (#10532): the `packs.repos` row in
3 * `forge_setting`, a JSON list of `owner/name`. /packs shows exactly these and
4 * nothing else; a repo not on the list is not a pack, whatever it holds.
5 * Edited on /settings/packs, with every change kept in `forge_setting_change`.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { isSafeName } from "../git/guard.ts";
10
11 export const PACKS_REPOS_KEY = "packs.repos";
12 export const MAX_MARKED = 20;
13
14 /** `owner/name` words from free text, and the words that were not that. */
15 export function parseMarked(text: string): { repos: string[]; rejected: string[] } {
16 const repos: string[] = [];
17 const rejected: string[] = [];
18 for (const word of text.slice(0, 4000).split(/[\s,]+/)) {
19 if (word === "") continue;
20 const [owner = "", name = "", ...rest] = word.split("/");
21 const full = `${owner}/${name}`.toLowerCase();
22 if (rest.length > 0 || !isSafeName(owner) || !isSafeName(name) || (repos.length >= MAX_MARKED && !repos.includes(full)))
23 rejected.push(word.slice(0, 60));
24 else if (!repos.includes(full)) repos.push(full);
25 }
26 return { repos, rejected };
27 }
28
29 /** The marked repos, in the order they were listed. Unset or unreadable is none. */
30 export function markedRepos(db: Database): string[] {
31 try {
32 const row = db.query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1").get(PACKS_REPOS_KEY);
33 const parsed: unknown = row ? JSON.parse(row.value) : [];
34 return Array.isArray(parsed) ? parseMarked(parsed.filter((v) => typeof v === "string").join(" ")).repos : [];
35 } catch {
36 return [];
37 }
38 }
39
40 /** Replace the list and keep the change. */
41 export function saveMarkedRepos(db: Database, repos: readonly string[], actorId: string): void {
42 const at = new Date().toISOString();
43 const to = JSON.stringify(repos);
44 db.transaction(() => {
45 const from = db.query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1").get(PACKS_REPOS_KEY)?.value;
46 db.query(
47 `INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES (?1, ?2, ?3, ?4)
48 ON CONFLICT (key) DO UPDATE SET value = excluded.value,
49 updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
50 ).run(PACKS_REPOS_KEY, to, actorId, at);
51 db.query(
52 `INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at) VALUES (?1, ?2, ?3, ?4, ?5)`,
53 ).run(PACKS_REPOS_KEY, from ?? "[]", to, actorId, at);
54 })();
55 }
56
57 export type MarkedUpdate =
58 | { ok: true; repos: string[] }
59 | { ok: false; error: "forbidden" }
60 | { ok: false; error: "rejected"; rejected: string[] };
61
62 /** The one door: an admin who is not an agent; check, save, keep. */
63 export function updateMarkedRepos(
64 db: Database,
65 user: { id: string; isAdmin: boolean; kind: string } | undefined,
66 text: string,
67 ): MarkedUpdate {
68 if (user === undefined || !user.isAdmin || user.kind === "agent") return { ok: false, error: "forbidden" };
69 const { repos, rejected } = parseMarked(text);
70 if (rejected.length > 0) return { ok: false, error: "rejected", rejected };
71 saveMarkedRepos(db, repos, user.id);
72 return { ok: true, repos };
73 }
74
75 export const rejectedMarkedSentence = (rejected: readonly string[]): string =>
76 `Not owner/name repositories: ${rejected.slice(0, 5).join(", ")}${rejected.length > 5 ? " …" : ""}. Use words like packs/games.`;
src/packs/mirror.ts +1 −1
@@ -1,6 +1,6 @@
11 /**
22 * Bringing the packs' upstreams in (#10530): each https `source` named in
3 * packs/*\/pack.json becomes one public repository `packs/<app>` that follows
3 * a marked pack repo's pack.json becomes one public repository `packs/<app>` that follows
44 * its upstream through the follow the forge already has
55 * (`actions/upstream_follow.ts`). The request only creates the repository and
66 * its follow row; the scheduled follow makes the first sync. The follow moves a mirror's default branch
src/packs/set.ts added +25
@@ -0,0 +1,25 @@
1 /**
2 * Changing which repos are packs, for every door (#10532): the web page, the
3 * terminal and MCP all come here, so a write is checked, kept and read into
4 * the catalog the same way, and shows on /packs at once.
5 */
6
7 import type { App } from "../app.ts";
8 import { refreshCatalogQuietly } from "./catalog.ts";
9 import { updateMarkedRepos, type MarkedUpdate } from "./marked.ts";
10
11 export type SetPacks =
12 | { ok: true; repos: string[]; problems: readonly string[] }
13 | Exclude<MarkedUpdate, { ok: true }>;
14
15 /** Save the marked repos as `user` (an admin who is not an agent), then re-read git. */
16 export async function setPackRepos(
17 app: App,
18 user: { id: string; isAdmin: boolean; kind: string } | undefined,
19 text: string,
20 ): Promise<SetPacks> {
21 const saved = updateMarkedRepos(app.db, user, text);
22 if (!saved.ok) return saved;
23 const { problems } = await refreshCatalogQuietly(app);
24 return { ok: true, repos: saved.repos, problems };
25 }
src/tui/screens/entries.ts +2
@@ -42,6 +42,7 @@ import { entry as loopStalls } from "./loopstalls.ts";
4242 import { entry as slowPagesEntry } from "./slowpages.ts";
4343 import { entry as maintenance } from "./maintenance.ts";
4444 import { entry as mirrorPacksEntry } from "./mirrorpacks.ts";
45 import { entry as packReposEntry } from "./packrepos.ts";
4546 import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
4647 import { entry as orgGrants } from "./orggrants.ts";
4748 import { entry as orgTeams } from "./orgteams.ts";
@@ -96,6 +97,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
9697 resilience,
9798 maintenance,
9899 mirrorPacksEntry,
100 packReposEntry,
99101 valves,
100102 access,
101103 edgeOnly,
src/tui/screens/packrepos.ts added +82
@@ -0,0 +1,82 @@
1 /**
2 * "Pack repositories" in the terminal (#10532): the list Settings › Packs
3 * edits, through the same door (`packs/set.ts`), so a change shows on /packs
4 * at once. Offered to site admins only; the action refuses anyone else.
5 * Enter or e edits the list as one line, Esc or q closes.
6 */
7
8 import { markedRepos, rejectedMarkedSentence } from "../../packs/marked.ts";
9 import { setPackRepos } from "../../packs/set.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import type { Session } from "../session.ts";
13 import { wrap } from "../term/width.ts";
14 import { dialog } from "../ui/widgets.ts";
15 import { promptScreen } from "./common.ts";
16 import type { PaletteEntry } from "./entries.ts";
17
18 const WIDTH = 72;
19 const INNER = WIDTH - 6;
20
21 export function packReposScreen(f: Session, ctx: Ctx): Screen {
22 let note = "";
23 const edit = (): void => {
24 ctx.push(
25 promptScreen(ctx, screen, {
26 title: "Pack repositories",
27 label: "owner/name, separated by spaces",
28 value: markedRepos(f.app.db).join(" "),
29 hint: "Public repos with a pack.json at the root, like packs/games.",
30 onSubmit: (text) => {
31 const saved = setPackRepos(f.app, f.user, text);
32 return saved.then((done) => {
33 note = done.ok
34 ? done.problems.length === 0
35 ? "Saved. /packs shows them now."
36 : `Saved; left out: ${done.problems.join("; ")}`
37 : done.error === "forbidden"
38 ? "Only an admin sets the pack repositories."
39 : rejectedMarkedSentence(done.rejected);
40 ctx.invalidate();
41 });
42 },
43 }),
44 );
45 };
46 const screen: Screen = {
47 title: "Pack repositories",
48 keys: [binding(["enter", "e"], "edit"), binding(["esc", "q"], "close")],
49 render(area, s) {
50 const marked = markedRepos(f.app.db);
51 const buttons = dialog(s, area, ctx.theme.c, {
52 title: "Pack repositories",
53 body: [
54 ...wrap(marked.length === 0 ? "None marked: /packs is empty." : marked.join(" "), INNER),
55 ...(note === "" ? [] : ["", ...wrap(note, INNER)]),
56 ],
57 actions: [{ label: "Edit" }, { label: "Close" }],
58 selected: 0,
59 width: WIDTH,
60 });
61 buttons[0] && ctx.hit(buttons[0], edit);
62 buttons[1] && ctx.hit(buttons[1], () => ctx.pop());
63 },
64 key(e) {
65 if (e.ctrl || e.alt) return true;
66 if (e.key === "esc" || e.key === "q") ctx.pop();
67 else if (e.key === "enter" || e.key === "e") edit();
68 return true;
69 },
70 };
71 return screen;
72 }
73
74 /** The repos /packs lists: a site admin's, as Settings › Packs is. */
75 export const entry: PaletteEntry = {
76 id: "pack-repos",
77 title: "Pack repositories",
78 hint: "which repositories /packs lists",
79 after: ["mirror-packs", "packs"],
80 offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent",
81 run: (session, ctx) => ctx.push(packReposScreen(session, ctx)),
82 };
src/tui/screens/packs.ts +8 −7
@@ -5,7 +5,8 @@
55 * prints. Anyone may open it; it reads the catalog, nothing of the forge's.
66 */
77
8 import { catalog } from "../../packs/catalog.ts";
8 import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts";
9 import type { App } from "../../app.ts";
910 import type { Pack } from "../../packs/pack.ts";
1011 import { BRINGS_WORDS, deployCommands, STRANGER_WORDS } from "../../packs/words.ts";
1112 import type { Ctx, Screen } from "../app/app.ts";
@@ -61,8 +62,8 @@ function packScreen(ctx: Ctx, p: Pack, origin: string | undefined): Screen {
6162 };
6263 }
6364
64 export function packsScreen(ctx: Ctx, origin: string | undefined): Screen {
65 const { packs } = catalog();
65 export function packsScreen(ctx: Ctx, origin: string | undefined, app?: App): Screen {
66 if (app !== undefined) void refreshCatalogQuietly(app, 2_000).then(() => ctx.invalidate());
outdated

No .catch: a failed refresh is an unhandled rejection. Add a catch. The same applies to src/web/studio/home.ts line 741.

6667 let at = 0;
6768 return {
6869 title: "Forge packs",
@@ -70,7 +71,7 @@ export function packsScreen(ctx: Ctx, origin: string | undefined): Screen {
7071 render(area, s) {
7172 const c = ctx.theme.c;
7273 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 catalog().packs.forEach((p, i) => {
7475 const on = i === at;
7576 const row = `${on ? "> " : " "}${p.title} v${p.version} ${p.apps.length} apps, ${p.crew.length} crew ${p.description}`;
7677 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);
@@ -79,10 +80,10 @@ export function packsScreen(ctx: Ctx, origin: string | undefined): Screen {
7980 key(e) {
8081 if (e.ctrl || e.alt) return false;
8182 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 === "j" || e.key === "down") at = Math.min(catalog().packs.length - 1, at + 1);
8384 else if (e.key === "k" || e.key === "up") at = Math.max(0, at - 1);
8485 else if (e.key === "enter") {
85 const p = packs[at];
86 const p = catalog().packs[at];
8687 if (p !== undefined) ctx.push(packScreen(ctx, p, origin));
8788 } else return false;
8889 return true;
@@ -94,5 +95,5 @@ export const packsCommand = (session: Session, ctx: Ctx) => ({
9495 id: "packs",
9596 title: "Forge packs",
9697 hint: "premade forges to deploy, and how to help build their apps",
97 run: () => ctx.push(packsScreen(ctx, session.app.config.url)),
98 run: () => ctx.push(packsScreen(ctx, session.app.config.url, session.app)),
9899 });
src/web/pages/origins.ts +2
@@ -114,6 +114,7 @@ export type AccountSection =
114114 | "economics"
115115 | "flags"
116116 | "features"
117 | "packs"
117118 | "cloud";
118119
119120 /**
@@ -161,6 +162,7 @@ const SECTION_ROWS: readonly (readonly [
161162 ["This forge", "Flows", "/admin/flows", "activity", (c) => c === "adminflows", true],
162163 ["This forge", "Flags", "/settings/flags", "activity", (c) => c === "flags", true],
163164 ["This forge", "Features", "/settings/features", "rocket", (c) => c === "features", true],
165 ["This forge", "Packs", "/settings/packs", "rocket", (c) => c === "packs", true],
164166 ["This forge", "Backups", "/settings/backups", "download", (c) => c === "backups", true],
165167 ];
166168
src/web/pages/packs-settings.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * Settings › Packs (#10532), for a site admin: the repos /packs shows. A pack
3 * is a public forge repo with a `pack.json` at its root; only the repos listed
4 * here are read, so a repo not marked is not a pack. The rule lives in
5 * `packs/marked.ts`; this page asks, shows and, once saved, re-reads git so
6 * the change is on /packs at once.
7 */
8
9 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
10 import { refreshCatalogQuietly } from "../../packs/catalog.ts";
11 import { setPackRepos } from "../../packs/set.ts";
12 import { markedRepos, rejectedMarkedSentence } from "../../packs/marked.ts";
13 import type { Ctx } from "../../http/context.ts";
14 import { notFoundPage, pageHead, settingsLayout, signInFirst } from "../chrome.ts";
15 import { field, readForm } from "../form.ts";
16 import { html, when, type Html } from "../html.ts";
17 import { flashTo, page } from "../layout.ts";
18 import { btn, field as uiField, row } from "../ui.ts";
19 import { accountSections } from "./origins.ts";
20
21 function requireAdmin(ctx: Ctx): Response | undefined {
22 if (!ctx.auth.user) return signInFirst(ctx);
23 if (!ctx.auth.user.isAdmin || ctx.auth.user.kind === "agent") return notFoundPage(ctx);
24 return undefined;
25 }
26
27 /** A problem as the catalog words it, "owner/name: reason", split into its two halves. */
28 const problemRow = (p: string) => {
29 const at = p.indexOf(": ");
30 return row({
outdated

Principle 1: the paragraph says what the page is for three times ("exactly these", "each must be", "separate with"). Use one confident sentence in pageHead's sub, and keep only the format hint beside the field.

31 title: html`<span class="mono">${at < 0 ? p : p.slice(0, at)}</span>`,
32 meta: html`${at < 0 ? "" : p.slice(at + 2)}`,
33 });
34 };
35
36 function view(ctx: Ctx, problems: readonly string[], text: string, error?: string, status = 200): Response {
37 const body: Html = html`<section class="settings-block">
38 <form method="post" action="/settings/packs">
39 ${uiField({ label: "Repositories", name: "repos", type: "textarea", rows: 3, value: text, mono: true, spellcheck: false, hint: "owner/name, like packs/games; separated by a space, comma or new line.", ...(error ? { error } : {}) })}
40 <div class="form-actions">${btn("Save", { variant: "primary" })}</div>
41 </form>
42 </section>
43 ${when(problems.length > 0, () => html`<section class="settings-block">
44 <h2>Left out</h2>
outdated

Principle 2, and the named components: this hand-rolls <div class="page-head"><h1> where /packs uses pageHead(...). Use pageHead. The problems are shown as an unlabelled list of red items; show each as a row naming the repository and the reason.

45 <ul class="list">${problems.map(problemRow)}</ul>
46 </section>`)}`;
47 return page(
48 {
49 ctx,
50 title: "Packs",
51 body: html`${pageHead("Packs", { sub: "/packs lists the public repositories named here, each with a pack.json at its root, and no others." })}${settingsLayout(accountSections("packs", { admin: true }), body, "Account settings")}`,
52 },
53 status,
54 );
55 }
56
57 // GET /settings/packs
58 export async function packsSettingsPage(ctx: Ctx): Promise<Response> {
59 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
60 const denied = requireAdmin(ctx);
61 if (denied) return denied;
62 const { problems } = await refreshCatalogQuietly(ctx.app);
63 const rendering = performance.now();
64 const response = view(ctx, problems, markedRepos(ctx.app.db).join("\n"));
65 notePhase(ctx.request, "render", performance.now() - rendering);
66 return response;
67 }
68
69 // POST /settings/packs
70 export async function packsSettingsSave(ctx: Ctx): Promise<Response> {
71 const denied = requireAdmin(ctx);
72 if (denied) return denied;
73 const form = await readForm(ctx, "admin");
74 if (form instanceof Response) return form;
75 const text = field(form, "repos");
76 const saved = await setPackRepos(ctx.app, ctx.auth.user, text);
77 if (!saved.ok) {
78 if (saved.error === "forbidden") return notFoundPage(ctx);
79 return view(ctx, [], text.slice(0, 4000), rejectedMarkedSentence(saved.rejected), 422);
80 }
81 return flashTo(ctx, "/settings/packs", {
82 message: saved.problems.length === 0 ? "Pack repositories saved." : `Pack repositories saved; ${saved.problems.length} could not be read.`,
83 });
84 }
src/web/pages/packs.ts +8 −5
@@ -6,7 +6,7 @@
66
77 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
88 import type { Pack } from "../../packs/pack.ts";
9 import { catalog, packNamed } from "../../packs/catalog.ts";
9 import { refreshCatalogQuietly } from "../../packs/catalog.ts";
1010 import type { Ctx } from "../../http/context.ts";
1111 import { notFoundPage, pageHead } from "../chrome.ts";
1212 import { deployCommands } from "../../packs/words.ts";
@@ -29,11 +29,14 @@ export const packsList = (packs: readonly Pack[]) =>
2929 }),
3030 )}</ul>`;
3131
32 /** A pack pushed to its repo shows within this long. */
33 const CATALOG_FRESH_MS = 2_000;
34
3235 // GET /packs
33 export function packsPage(ctx: Ctx): Response {
36 export async function packsPage(ctx: Ctx): Promise<Response> {
3437 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
3538 const started = performance.now();
36 const { packs } = catalog();
39 const { packs } = await refreshCatalogQuietly(ctx.app, CATALOG_FRESH_MS);
3740 const body = html`${pageHead("Forge packs", {
3841 sub: "A whole forge to start from: real apps with their history, and a crew set up to work on them.",
3942 })}
@@ -50,10 +53,10 @@ export function packsPage(ctx: Ctx): Response {
5053 }
5154
5255 // GET /packs/:name
53 export function packPage(ctx: Ctx): Response {
56 export async function packPage(ctx: Ctx): Promise<Response> {
5457 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
5558 const started = performance.now();
56 const pack = packNamed(ctx.params.name ?? "");
59 const pack = (await refreshCatalogQuietly(ctx.app, CATALOG_FRESH_MS)).packs.find((p) => p.name === (ctx.params.name ?? ""));
5760 if (pack === undefined) return notFoundPage(ctx);
5861 const commands = deployCommands(pack, ctx.app.config.url);
5962 const body = html`${pageHead(`${pack.title} pack`, { sub: pack.description })}
src/web/studio/home.ts +3 −1
@@ -17,7 +17,7 @@ import {
1717 type FrontPage,
1818 type FrontPicksJson,
1919 } from "../../actions/front.ts";
20 import { catalog } from "../../packs/catalog.ts";
20 import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts";
2121 import { appsRuntime, type EnvState } from "../../apps/runtime.ts";
2222 import { signupClosed } from "../../actions/users.ts";
2323 import { getSignup } from "../../settings/signup.ts";
@@ -737,6 +737,8 @@ function agentPrompt(origin: string): string {
737737 * controls that decide it.
738738 */
739739 export function studioDoor(ctx: Ctx): Response {
740 // The door draws what is in memory; a pack pushed since is picked up for the next visit.
741 void refreshCatalogQuietly(ctx.app, 5_000);
outdated

A fire-and-forget promise with no .catch on the front door. A rejected read becomes an unhandled rejection; catch it.

740742 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
741743 let tiles: Html[] = [];
742744 const front = timePhase(ctx.request, "shelf", () => frontPage(ctx.app, Date.now(), ctx.request));
test/dockerfile-copies.test.ts +3 −4
@@ -37,9 +37,8 @@ describe("what the Dockerfile copies reaches the build", () => {
3737 expect(ignored(lines(".dockerignore"), path)).toBe(false);
3838 });
3939
40 // /packs rendered blank in production: the catalog reads packs/ beside src/, and the
41 // image carried only src/. Each folder a page reads from outside src/ is copied.
42 test("the packs /packs lists are in the image", () => {
43 expect(copied).toContain("packs");
40 // The packs are forge repos read from git (#10532): no folder of them rides in the image.
41 test("no packs folder is copied: /packs reads the marked repos", () => {
42 expect(copied).not.toContain("packs");
4443 });
4544 });
packs/business/pack.json → test/fixtures/packs/business.json renamed
No content changes.
packs/games/pack.json → test/fixtures/packs/games.json renamed
No content changes.
test/helpers/packs.ts added +55
@@ -0,0 +1,55 @@
1 /**
2 * Packs as the forge has them (#10532): forge repos `packs/<name>` holding a
3 * pack.json, marked in `packs.repos`. `seedPacks` makes the two the tests walk
4 * (games, business) from test/fixtures/packs and reads them into the catalog.
5 */
6
7 import { readFileSync } from "node:fs";
8 import { join } from "node:path";
9 import type { App } from "../../src/app.ts";
10 import { createRepo } from "../../src/core/repos.ts";
11 import { createUser, getUserByName } from "../../src/core/users.ts";
12 import { commitFiles } from "../../src/git/write.ts";
13 import { repoDir } from "../../src/git/repos.ts";
14 import { refreshCatalog } from "../../src/packs/catalog.ts";
15 import { markedRepos, saveMarkedRepos } from "../../src/packs/marked.ts";
16 import { parsePack, type Pack } from "../../src/packs/pack.ts";
17
18 const FIXTURES = join(import.meta.dir, "..", "fixtures", "packs");
19
20 /** The text of a fixture pack.json. */
21 export const fixtureText = (name: string): string => readFileSync(join(FIXTURES, `${name}.json`), "utf8");
22
23 /** The two fixture packs, parsed, with no forge at all. */
24 export const fixturePacks = (): Pack[] =>
25 ["business", "games"].map((n) => {
26 const r = parsePack(fixtureText(n));
27 if (!r.ok) throw new Error(r.error);
28 return r.value;
29 });
30
31 /** Make `packs/<name>` with `text` as its pack.json; mark it when `mark`. */
32 export async function seedPackRepo(app: App, name: string, text: string, mark = true): Promise<void> {
33 const owner =
34 getUserByName(app.db, "packs") ??
35 (await (async () => {
36 const made = await createUser(app.db, { origin: "seed", trusted: true, allowReserved: true, username: "packs", email: "[email protected]", displayName: "Forge packs" });
37 if (!made.ok) throw new Error(made.error);
38 return made.value;
39 })());
40 const repo = await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name, visibility: "public", description: "A pack.", defaultBranch: "main" });
41 if (!repo.ok) throw new Error(repo.error);
42 await commitFiles(repoDir(app.reposRoot, "packs", name), {
43 branch: "main",
44 message: "pack.json",
45 files: [{ path: "pack.json", content: text }],
46 author: { name: "packs", email: "[email protected]" },
47 });
48 if (mark) saveMarkedRepos(app.db, [...markedRepos(app.db), `packs/${name}`], owner.id);
49 }
50
51 /** Games and Business as marked pack repos, read into the catalog. */
52 export async function seedPacks(app: App): Promise<void> {
53 for (const n of ["games", "business"]) await seedPackRepo(app, n, fixtureText(n));
54 await refreshCatalog(app);
55 }
test/keys-packs.test.ts +2
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { catalog } from "../src/packs/catalog.ts";
8 import { seedPacks } from "./helpers/packs.ts";
89 import { packsList } from "../src/web/pages/packs.ts";
910 import { serve, type Servers } from "../src/http/server.ts";
1011
@@ -15,6 +16,7 @@ let servers: Servers;
1516 beforeAll(async () => {
1617 dataDir = await mkdtemp(join(tmpdir(), "forge-keys-packs-"));
1718 app = createApp({ dataDir, port: 0 });
19 await seedPacks(app);
1820 servers = await serve(app);
1921 });
2022
test/packs-catalog.test.ts added +192
@@ -0,0 +1,192 @@
1 /**
2 * The catalog and the doors to the marked list (#10532): the max-age path, reads that
3 * overlap, a repo that turns private, the three ways to mark (page, terminal, MCP) and
4 * `fetchPack`, which reads a pack from a forge at its default branch.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { updateRepoSettings } from "../src/actions/repos.ts";
12 import { getRepo } from "../src/core/repos.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { tool } from "../src/http/mcp/tools/repo_settings.ts";
16 import { catalog, refreshCatalog, refreshCatalogQuietly } from "../src/packs/catalog.ts";
17 import { markedRepos, saveMarkedRepos } from "../src/packs/marked.ts";
18 import { setPackRepos } from "../src/packs/set.ts";
19 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
20 import { packReposScreen } from "../src/tui/screens/packrepos.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { fetchPack } from "../scripts/pack.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { fixtureText, seedPackRepo } from "./helpers/packs.ts";
25 import { settleApp } from "./helpers/tui.ts";
26
27 quietLogs();
28
29 let dir: string;
30 let app: App;
31 let servers: Servers;
32 let admin: User;
33 let bob: User;
34 const names = (): string[] => catalog().packs.map((p) => p.name);
35
36 beforeAll(async () => {
37 dir = await mkdtemp(join(tmpdir(), "forge-packs-catalog-"));
38 app = createApp({ dataDir: dir, port: 0 });
39 servers = await serve(app);
40 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
41 const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
42 if (!a.ok || !b.ok) throw new Error("users");
43 admin = a.value;
44 bob = b.value;
45 await seedPackRepo(app, "games", fixtureText("games"), false);
46 await seedPackRepo(app, "business", fixtureText("business"), false);
47 });
48 afterAll(async () => {
49 await servers.stop();
50 app.close();
51 await rm(dir, { recursive: true, force: true });
52 });
53
54 describe("refreshCatalog", () => {
55 test("within max age it answers from memory and does not read again", async () => {
56 saveMarkedRepos(app.db, ["packs/games"], admin.id);
57 await refreshCatalog(app);
58 expect(names()).toEqual(["games"]);
59 saveMarkedRepos(app.db, ["packs/games", "packs/business"], admin.id);
60 expect((await refreshCatalog(app, 60_000)).packs.map((p) => p.name)).toEqual(["games"]);
61 expect((await refreshCatalog(app, 0)).packs.map((p) => p.name)).toEqual(["business", "games"]);
62 });
63
64 test("overlapping reads end on the newest list, and each caller sees what was saved before it asked", async () => {
65 saveMarkedRepos(app.db, ["packs/games"], admin.id);
66 const first = refreshCatalog(app);
67 saveMarkedRepos(app.db, ["packs/business"], admin.id);
68 const second = refreshCatalog(app);
69 const third = refreshCatalog(app);
70 expect((await second).packs.map((p) => p.name)).toEqual(["business"]);
71 expect(await third).toBe(await second);
72 await first;
73 expect(names()).toEqual(["business"]);
74 });
75
76 test("a repo that turns private after it was marked leaves the catalog and is named", async () => {
77 saveMarkedRepos(app.db, ["packs/games", "packs/business"], admin.id);
78 expect((await refreshCatalog(app)).packs).toHaveLength(2);
79 const owner = getUserOwner();
80 const done = await updateRepoSettings(app, owner, getRepo(app.db, "packs", "games")!, { visibility: "private" });
81 expect(done.ok).toBe(true);
82 const after = await refreshCatalog(app);
83 expect(after.packs.map((p) => p.name)).toEqual(["business"]);
84 expect(after.problems.join(" ")).toContain("packs/games: a pack repository must be public");
85 await updateRepoSettings(app, owner, getRepo(app.db, "packs", "games")!, { visibility: "public" });
86 });
87
88 test("a read that throws is logged and leaves the catalog as it was", async () => {
89 await refreshCatalog(app);
90 const before = names();
91 const broken = Object.create(app, { reposRoot: { get: () => { throw new Error("disk gone"); } } }) as App;
92 expect((await refreshCatalogQuietly(broken)).packs.map((p) => p.name)).toEqual(before);
93 expect(names()).toEqual(before);
94 await expect(refreshCatalog(app)).resolves.toBeDefined();
95 });
96 });
97
98 function getUserOwner(): User {
99 return { ...admin, isAdmin: true };
100 }
101
102 describe("the doors that mark", () => {
103 test("setPackRepos is an admin's, and shows in the catalog at once", async () => {
104 expect((await setPackRepos(app, bob, "packs/games")).ok).toBe(false);
105 expect((await setPackRepos(app, undefined, "packs/games")).ok).toBe(false);
106 const bad = await setPackRepos(app, admin, "not a repo/x/y");
107 expect(bad.ok).toBe(false);
108 const done = await setPackRepos(app, admin, "packs/games");
109 expect(done).toMatchObject({ ok: true, repos: ["packs/games"] });
110 expect(names()).toEqual(["games"]);
111 });
112
113 test("MCP: an admin sets the list, a stranger is refused, a bad word is invalid", async () => {
114 const ctx = { app } as never;
115 expect((await tool.run(ctx, bob, { packRepos: ["packs/games"] })).ok).toBe(false);
116 expect((await tool.run(ctx, admin, { packRepos: ["a/b/c"] })).ok).toBe(false);
117 expect((await tool.run(ctx, admin, { packRepos: "packs/games" as never })).ok).toBe(false);
118 const done = await tool.run(ctx, admin, { packRepos: ["packs/business", "packs/ghost"] });
119 expect(done.ok).toBe(true);
120 expect(JSON.stringify(done)).toContain("packs/ghost: no such repository");
121 expect(names()).toEqual(["business"]);
122 expect(markedRepos(app.db)).toEqual(["packs/business", "packs/ghost"]);
123 });
124
125 describe("the terminal", () => {
126 const open = (user: User) =>
127 createForgeApp(app, { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user }, { terminal: headlessTerminal(120, 40), exitHooks: false });
128
129 test("an admin is offered the screen and a non-admin is not", () => {
130 const a = open(admin);
131 const ids = forgeCommands(a.session)(a.app.ctx).map((c) => c.id);
132 a.app.stop?.();
133 const n = open(bob);
134 const idsN = forgeCommands(n.session)(n.app.ctx).map((c) => c.id);
135 n.app.stop?.();
136 expect(ids).toContain("pack-repos");
137 expect(idsN).not.toContain("pack-repos");
138 });
139
140 test("editing the list saves it and /packs shows it at once", async () => {
141 const { app: tui, session } = open(admin);
142 tui.start();
143 tui.ctx.push(packReposScreen(session, tui.ctx));
144 await settleApp(tui);
145 const key = (k: string, ctrl = false) => tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
146 key("e");
147 await settleApp(tui);
148 for (let i = 0; i < 40; i++) key("backspace");
149 for (const ch of "packs/games packs/business") key(ch);
150 key("enter");
151 await settleApp(tui);
152 await settleApp(tui);
153 expect(tui.screen.toText()).toContain("Saved");
154 expect(markedRepos(app.db)).toEqual(["packs/games", "packs/business"]);
155 const page = await (await fetch(`${servers.url}/packs`)).text();
156 expect(page).toContain(`href="/packs/games"`);
157 expect(page).toContain(`href="/packs/business"`);
158 tui.stop?.();
159 });
160 });
161 });
162
163 describe("fetchPack", () => {
164 const pack = fixtureText("games");
165 test("a 200 is the parsed pack, read at the repository's default branch", async () => {
166 let asked = "";
167 const got = await fetchPack("games", "https://forge.example/", (async (url: string) => {
168 asked = url;
169 return new Response(pack, { status: 200 });
170 }) as never);
171 expect(typeof got).toBe("object");
172 expect(asked).toBe("https://forge.example/packs/games/raw/HEAD/pack.json");
173 });
174 test("a 404 says there is no such pack", async () => {
175 const got = await fetchPack("nope", "https://forge.example", (async () => new Response("", { status: 404 })) as never);
176 expect(got).toContain("no pack named nope");
177 expect(got).toContain("404");
178 });
179 test("a pack.json that does not parse is named, not returned", async () => {
180 const got = await fetchPack("games", "https://forge.example", (async () => new Response("{not json", { status: 200 })) as never);
181 expect(typeof got).toBe("string");
182 expect(got).toContain("raw/HEAD/pack.json");
183 });
184 test("a network failure says which address could not be read", async () => {
185 const got = await fetchPack("games", "https://forge.example", (async () => { throw new Error("down"); }) as never);
186 expect(got).toBe("could not read https://forge.example/packs/games/raw/HEAD/pack.json");
187 });
188 test("this forge answers HEAD with the pack.json of the repository's default branch", async () => {
189 const got = await fetchPack("games", servers.url);
190 expect(got).toMatchObject({ name: "games" });
191 });
192 });
test/packs-hands.test.ts +4 −1
@@ -9,6 +9,7 @@ import { serve } from "../src/http/server.ts";
99 import { deployPack } from "../src/packs/deploy.ts";
1010 import { parsePack } from "../src/packs/pack.ts";
1111 import { packNamed } from "../src/packs/catalog.ts";
12 import { fixturePacks, seedPacks } from "./helpers/packs.ts";
1213 import { deployCommands } from "../src/packs/words.ts";
1314 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
1415 import { packLines, packsScreen } from "../src/tui/screens/packs.ts";
@@ -68,7 +69,7 @@ describe("makeAgent", () => {
6869 });
6970
7071 describe("deploy words", () => {
71 const games = packNamed("games")!;
72 const games = fixturePacks().find((p) => p.name === "games")!;
7273 test("the clone source is the configured address, never a request's Host", () => {
7374 expect(deployCommands(games, "https://f.example/")[0]).toContain("git clone https://f.example/trevato/forge.git");
7475 expect(deployCommands(games, undefined)[0]).not.toContain("http");
@@ -76,6 +77,7 @@ describe("deploy words", () => {
7677 test("the web page does not echo a forged Host", async () => {
7778 const dir = await mkdtemp(join(tmpdir(), "packs-host-"));
7879 const a = createApp({ dataDir: dir, port: 0 });
80 await seedPacks(a);
7981 const servers = await serve(a);
8082 try {
8183 const res = await fetch(`${servers.url}/packs/games`, { headers: { host: "evil.example" } });
@@ -97,6 +99,7 @@ describe("terminal screen", () => {
9799 app = createApp({ dataDir: dir, port: 0 });
98100 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
99101 if (!r.ok) throw new Error(r.error);
102 await seedPacks(app);
100103 });
101104 afterAll(async () => {
102105 app.close();
test/packs-mirror.test.ts +9 −2
@@ -22,6 +22,7 @@ import { serveGitCommand } from "../src/tui/ssh.ts";
2222 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
2323 import { mirrorPacksScreen } from "../src/tui/screens/mirrorpacks.ts";
2424 import { headlessTerminal } from "../src/tui/term/terminal.ts";
25 import { seedPacks } from "./helpers/packs.ts";
2526 import { settleApp } from "./helpers/tui.ts";
2627 import { tool } from "../src/http/mcp/tools/repo_settings.ts";
2728 import { mirrorPacks, packSources, reportWords } from "../src/packs/mirror.ts";
@@ -115,9 +116,15 @@ describe("packs mirrors", () => {
115116 ];
116117 let first = "";
117118
118 test("the packs' own https sources are what it would bring in", () => {
119 test("the packs' own https sources are what it would bring in", async () => {
120 const other = createApp({ dataDir: join(dataDir, "catalog"), port: 0 });
121 try {
122 await seedPacks(other);
123 } finally {
124 other.close();
125 }
119126 const names = packSources().map((s) => s.name);
120 expect(names).toContain("memos");
127 expect(names).toContain("hextris");
121128 expect(names).toContain("2048");
122129 expect(packSources().every((s) => s.source.startsWith("https://"))).toBe(true);
123130 });
test/packs-settings.test.ts added +76
@@ -0,0 +1,76 @@
1 /** Settings › Packs (#10532): an admin marks the repos /packs lists; a repo not marked is not listed. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createSession } from "../src/core/sessions.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { markedRepos } from "../src/packs/marked.ts";
11 import { adminCookie } from "./helpers/admin.ts";
12 import { fixtureText, seedPackRepo } from "./helpers/packs.ts";
13
14 let dir: string;
15 let app: App;
16 let servers: Servers;
17 let admin: string;
18 let plain: string;
19
20 beforeAll(async () => {
21 dir = await mkdtemp(join(tmpdir(), "forge-packs-set-"));
22 app = createApp({ dataDir: dir, port: 0 });
23 servers = await serve(app);
24 admin = await adminCookie(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
25 const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
26 if (!bob.ok) throw new Error(bob.error);
27 plain = `forge_session=${createSession(app.db, bob.value.id).token}`;
28 await seedPackRepo(app, "games", fixtureText("games"), false);
29 await seedPackRepo(app, "business", fixtureText("business"), false);
30 });
31 afterAll(async () => {
32 await servers.stop();
33 app.close();
34 await rm(dir, { recursive: true, force: true });
35 });
36
37 const save = (cookie: string, repos: string, origin = servers.url) =>
38 fetch(`${servers.url}/settings/packs`, {
39 method: "POST",
40 redirect: "manual",
41 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin },
42 body: new URLSearchParams({ repos }),
43 });
44 const listed = async () => await (await fetch(`${servers.url}/packs`)).text();
45
46 test("nothing is marked, so /packs lists no pack even though the repos exist", async () => {
47 const text = await listed();
48 expect(text).not.toContain(`href="/packs/games"`);
49 expect(text).not.toContain(`href="/packs/business"`);
50 });
51
52 test("only an admin sees the page or saves; a cross-origin post is refused", async () => {
53 expect((await fetch(`${servers.url}/settings/packs`, { headers: { cookie: plain } })).status).toBe(404);
54 expect((await save(plain, "packs/games")).status).not.toBe(303);
55 expect((await save(admin, "packs/games", "https://evil.example")).status).toBeGreaterThanOrEqual(400);
56 expect(markedRepos(app.db)).toEqual([]);
57 });
58
59 test("a save lists exactly the marked repo, at once, and keeps the change", async () => {
60 const res = await save(admin, "packs/games");
61 expect(res.status).toBe(303);
62 const text = await listed();
63 expect(text).toContain(`href="/packs/games"`);
64 expect(text).not.toContain(`href="/packs/business"`);
65 expect((await fetch(`${servers.url}/packs/business`)).status).toBe(404);
66 const changes = app.db.query<{ to_value: string }, []>("SELECT to_value FROM forge_setting_change WHERE key = 'packs.repos'").all();
67 expect(changes.at(-1)!.to_value).toBe('["packs/games"]');
68 const page = await (await fetch(`${servers.url}/settings/packs`, { headers: { cookie: admin } })).text();
69 expect(page).toContain("packs/games");
70 });
71
72 test("words that are not owner/name are refused and nothing changes", async () => {
73 const res = await save(admin, "packs/games ../etc");
74 expect(res.status).toBe(422);
75 expect(markedRepos(app.db)).toEqual(["packs/games"]);
76 });
test/packs-up.test.ts +8 −8
@@ -9,7 +9,7 @@ import { getAgentByName } from "../src/core/agents.ts";
99 import { getRepo } from "../src/core/repos.ts";
1010 import { getFollow } from "../src/core/upstream_follow.ts";
1111 import { serve, type Servers } from "../src/http/server.ts";
12 import { catalog } from "../src/packs/catalog.ts";
12 import { fixturePacks } from "./helpers/packs.ts";
1313 import { deployPack } from "../src/packs/deploy.ts";
1414 import { addSubscription } from "../src/core/subscriptions.ts";
1515 import { checkUpstream } from "../src/actions/upstream_follow.ts";
@@ -55,7 +55,7 @@ beforeAll(async () => {
5555 passphrase = prepared.passphrase;
5656 // A fake source for every app of both packs: three commits and a tag, on a branch that is not "main" for one of them.
5757 let n = 0;
58 for (const pack of catalog().packs)
58 for (const pack of fixturePacks())
5959 for (const a of pack.apps) {
6060 const dir = join(root, `src-${n++}`);
6161 git(root, "init", "-q", "-b", n === 1 ? "master" : "main", dir);
@@ -78,7 +78,7 @@ afterAll(async () => {
7878
7979 describe("the one command, against a fake source", () => {
8080 test("the command is one line to run after clone and install, and asks for no token", () => {
81 for (const p of catalog().packs) {
81 for (const p of fixturePacks()) {
8282 const lines = deployCommands(p, "https://f.example");
8383 expect(lines.some((l) => l.startsWith(`bun scripts/pack.ts up ${p.name}`))).toBe(true);
8484 expect(lines[0]).toContain("bun install");
@@ -103,7 +103,7 @@ describe("the one command, against a fake source", () => {
103103
104104 for (const name of ["games", "business"]) {
105105 test(`${name}: every app arrives with history, followed; the crew is configured; no secret or variable travels`, async () => {
106 const pack = catalog().packs.find((p) => p.name === name)!;
106 const pack = fixturePacks().find((p) => p.name === name)!;
107107 const steps = await deployPack(pack, realHands(servers.url, token, { cloneFrom: (s) => fake.get(s)! }));
108108 expect(steps.filter((s) => s.state !== "done")).toEqual([]);
109109 for (const a of pack.apps) {
@@ -130,7 +130,7 @@ describe("the one command, against a fake source", () => {
130130 }
131131
132132 test("running it again keeps what exists", async () => {
133 const pack = catalog().packs[0]!;
133 const pack = fixturePacks()[0]!;
134134 const steps = await deployPack(pack, realHands(servers.url, token, { cloneFrom: (s) => fake.get(s)! }));
135135 expect(steps.map((s) => `${s.what} ${s.state} ${s.note}`)).toEqual(steps.map((s) => `${s.what} kept already here`));
136136 });
@@ -159,7 +159,7 @@ describe("the one command, against a fake source", () => {
159159 });
160160
161161 test("a partial deploy is finished by running it again: a kept app that was never followed is followed", async () => {
162 const pack = catalog().packs[0]!;
162 const pack = fixturePacks()[0]!;
163163 const hands = realHands(servers.url, token, { cloneFrom: (s) => fake.get(s)! });
164164 // First run: the follow of the first app fails after the app and its history are in.
165165 const one = { ...pack, apps: [{ ...pack.apps[0]!, name: "partial-app" }], crew: [] };
@@ -184,7 +184,7 @@ describe("the one command, against a fake source", () => {
184184 });
185185
186186 test("a new upstream commit shows as drift at the next check", async () => {
187 const pack = catalog().packs[0]!;
187 const pack = fixturePacks()[0]!;
188188 const a = pack.apps[0]!;
189189 const repo = getRepo(app.db, OWNER, a.name)!;
190190 const src = fake.get(a.source)!;
@@ -234,7 +234,7 @@ describe("up() itself", () => {
234234 const env: Record<string, string | undefined> = { ...process.env, FORGE_SSH_PORT: "0" };
235235 for (const n of ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "FORGE_CLAUDE_CONFIG_DIR"]) delete env[n];
236236 try {
237 const pack = catalog().packs[0]!;
237 const pack = fixturePacks()[0]!;
238238 const running = up(pack, { port, dataDir: d, cloneFrom: (s) => fake.get(s)!, say: (l) => lines.push(l), signal: stop.signal, env });
239239 let ended: number | undefined;
240240 void running.then((c) => (ended = c));
test/packs.test.ts +38 −6
@@ -5,7 +5,10 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { serve, type Servers } from "../src/http/server.ts";
8 import { catalog } from "../src/packs/catalog.ts";
8 import { catalog, refreshCatalog } from "../src/packs/catalog.ts";
9 import { getUserByName } from "../src/core/users.ts";
10 import { markedRepos, parseMarked, saveMarkedRepos, updateMarkedRepos } from "../src/packs/marked.ts";
11 import { fixtureText, seedPackRepo, seedPacks } from "./helpers/packs.ts";
912 import { deployPack, type Hands } from "../src/packs/deploy.ts";
1013 import { parsePack, sourceOf } from "../src/packs/pack.ts";
1114
@@ -104,12 +107,40 @@ describe("pack absorb", () => {
104107 });
105108 });
106109
107 describe("shipped packs", () => {
108 test("Games and Business ship and parse", () => {
109 const c = catalog();
110 describe("the catalog reads marked repos from git", () => {
111 let dir: string;
112 let app: App;
113 beforeAll(async () => {
114 dir = await mkdtemp(join(tmpdir(), "forge-packs-cat-"));
115 app = createApp({ dataDir: dir, port: 0 });
116 });
117 afterAll(async () => {
118 app.close();
119 await rm(dir, { recursive: true, force: true });
120 });
121 test("nothing is marked, nothing is listed; a marked repo with a pack.json is, an unmarked one is not", async () => {
122 expect((await refreshCatalog(app)).packs).toEqual([]);
123 await seedPackRepo(app, "games", fixtureText("games"));
124 await seedPackRepo(app, "business", fixtureText("business"), false);
125 const c = await refreshCatalog(app);
110126 expect(c.problems).toEqual([]);
111 expect(c.packs.map((p) => p.name)).toEqual(["business", "games"]);
112 for (const p of c.packs) expect(p.apps.length).toBeGreaterThan(0);
127 expect(c.packs.map((p) => p.name)).toEqual(["games"]);
128 expect(catalog().packs.length).toBe(1);
129 expect(c.packs[0]!.apps.length).toBeGreaterThan(0);
130 });
131 test("a marked repo that is missing, has no pack.json or a bad one is named, not listed", async () => {
132 await seedPackRepo(app, "empty", "{}", false);
133 await seedPackRepo(app, "wrong", fixtureText("games").replace('"name": "games"', '"name": "other"'), false);
134 saveMarkedRepos(app.db, ["packs/games", "packs/gone", "packs/empty", "packs/wrong"], getUserByName(app.db, "packs")!.id);
135 const c = await refreshCatalog(app);
136 expect(c.packs.map((p) => p.name)).toEqual(["games"]);
137 expect(c.problems.map((p) => p.split(":")[0])).toEqual(["packs/gone", "packs/empty", "packs/wrong"]);
138 });
139 test("the setting accepts owner/name words and only an admin changes it", () => {
140 expect(parseMarked("packs/games, Packs/Games a/b/c ../x nothing")).toEqual({ repos: ["packs/games"], rejected: ["a/b/c", "../x", "nothing"] });
141 expect(updateMarkedRepos(app.db, { id: "u", isAdmin: false, kind: "human" }, "packs/games")).toEqual({ ok: false, error: "forbidden" });
142 expect(updateMarkedRepos(app.db, { id: "u", isAdmin: true, kind: "agent" }, "packs/games")).toEqual({ ok: false, error: "forbidden" });
143 expect(markedRepos(app.db)).toContain("packs/gone");
113144 });
114145 });
115146
@@ -158,6 +189,7 @@ describe("pages", () => {
158189 beforeAll(async () => {
159190 dataDir = await mkdtemp(join(tmpdir(), "forge-packs-"));
160191 app = createApp({ dataDir, port: 0 });
192 await seedPacks(app);
161193 servers = await serve(app);
162194 });
163195 afterAll(async () => {
test/page-head.test.ts +2
@@ -8,6 +8,7 @@ import { getRepo } from "../src/core/repos.ts";
88 import { createIssue } from "../src/core/issues.ts";
99 import { createApp as createAppRecord } from "../src/core/apps.ts";
1010 import { createUser } from "../src/core/users.ts";
11 import { seedPacks } from "./helpers/packs.ts";
1112 import { serve, type Servers } from "../src/http/server.ts";
1213 import { appLine, plainDescription, repoLine } from "../src/web/describe.ts";
1314
@@ -33,6 +34,7 @@ describe("every public page names itself for a link preview", () => {
3334 adminIfFirst: true,
3435 });
3536 if (!u.ok) throw new Error(u.error);
37 await seedPacks(app);
3638 // No description at all, and one that is scaffold jargon.
3739 for (const name of ["bare", "widget"]) {
3840 const made = await createRepo(app, u.value, { name, visibility: "public" });
test/palette-roster.test.ts +1
@@ -49,6 +49,7 @@ test("a site-admin person is offered the whole roster", () => {
4949 ["explore", "Explore", "on the web"],
5050 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
5151 ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"],
52 ["pack-repos", "Pack repositories", "which repositories /packs lists"],
5253 ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"],
5354 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
5455 ["profile", "Open a profile", "username or org"],
test/public-head.test.ts +2
@@ -9,6 +9,7 @@ import { createIssue } from "../src/core/issues.ts";
99 import { createApp as createAppRecord } from "../src/core/apps.ts";
1010 import { createUser } from "../src/core/users.ts";
1111 import { catalog } from "../src/packs/catalog.ts";
12 import { seedPacks } from "./helpers/packs.ts";
1213 import { serve, type Servers } from "../src/http/server.ts";
1314
1415 /** The content of the first tag matching, or undefined when there is none. */
@@ -90,6 +91,7 @@ describe("every public page names itself", () => {
9091 }
9192 const rec = createAppRecord(app.db, { repoId: getRepo(app.db, "alice", "widget")!.id, slug: "widget", access: "public" });
9293 if (!rec.ok) throw new Error(rec.error);
94 await seedPacks(app);
9395 const pack = catalog().packs[0];
9496 if (!pack) throw new Error("the catalog ships no pack to walk");
9597 // Walked as a stranger. Left out, on purpose: redirects to sign in (/map,

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs