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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10532-packs-reads-pack-repos-the-owner-marks-a → main 4 commits fork 5 threads · 5 unresolved
Dockerfile −2
| @@ -23,8 +23,6 @@ COPY docs/PRINCIPLES.md ./docs/PRINCIPLES.md | ||
| 23 | 23 | COPY docs/DESIGN.md ./docs/DESIGN.md |
| 24 | 24 | # The OpenCode plugin the forge serves at /plugins/opencode/forge.ts (src/http/plugin_routes.ts). |
| 25 | 25 | 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 | |
| 28 | 26 | # src/ alone: nothing under it imports scripts/ or test/ (test/src-imports.test.ts), |
| 29 | 27 | # and the boot proof below fails the build if that ever stops being so (#2652). |
| 30 | 28 |
docs/design/attention-budget.json +2 −2
| @@ -109,12 +109,12 @@ | ||
| 109 | 109 | }, |
| 110 | 110 | "settings": { |
| 111 | 111 | "390": { |
| 112 | "words": 137, | |
| 112 | "words": 138, | |
| 113 | 113 | "blocks": 32, |
| 114 | 114 | "colours": 1 |
| 115 | 115 | }, |
| 116 | 116 | "1280": { |
| 117 | "words": 137, | |
| 117 | "words": 138, | |
| 118 | 118 | "blocks": 32, |
| 119 | 119 | "colours": 1 |
| 120 | 120 | } |
docs/design/audit.md +1
| @@ -115,6 +115,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 115 | 115 | - `/settings/backups` — `pages/backups.ts` (621 lines) · backups and restore · `/settings/resilience`, `/releases` · none seen. |
| 116 | 116 | - `/settings/flags` — `pages/flags.ts` · the forge's switches · none · none seen. |
| 117 | 117 | - `/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. | |
| 118 | 119 | - `/settings/model` — `pages/model.ts` · which model the crew runs on · `/settings/crew` · none seen. |
| 119 | 120 | - `/settings/cloud` — `pages/cloud.ts` · cloud provider connection · none · none seen. |
| 120 | 121 | - `/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 @@ | ||
| 2 | 2 | |
| 3 | 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 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. |
| 4 | 4 | |
| 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). | |
| 6 | 10 | |
| 7 | 11 | - **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`. |
| 8 | 12 | - **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 | ||
| 14 | 18 | |
| 15 | 19 | ## The upstream mirrors (#10530) |
| 16 | 20 | |
| 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. | |
| 18 | 22 | |
| 19 | 23 | 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"; | ||
| 14 | 14 | import { createToken } from "../src/core/tokens.ts"; |
| 15 | 15 | import { createUser } from "../src/core/users.ts"; |
| 16 | 16 | import { serve } from "../src/http/server.ts"; |
| 17 | import { catalog } from "../src/packs/catalog.ts"; | |
| 18 | 17 | 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"; | |
| 20 | 20 | |
| 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 | } | |
| 24 | 32 | |
| 25 | 33 | for (const pack of packs) { |
| 26 | 34 | const dataDir = await mkdtemp(join(tmpdir(), `pack-compat-${pack.name}-`)); |
scripts/pack.ts +20 −5
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * bun scripts/pack.ts show games |
| 6 | 6 | * FORGE_URL=http://localhost:3000 FORGE_TOKEN=forge_… bun scripts/pack.ts deploy games |
| 7 | 7 | * |
| 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 | |
| 9 | 9 | * each app in the forge at FORGE_URL (the token's owner owns them), copies the |
| 10 | 10 | * source's whole history in, and adds the crew. It brings no data, secret or |
| 11 | 11 | * 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"; | ||
| 16 | 16 | import { tmpdir } from "node:os"; |
| 17 | 17 | import { join } from "node:path"; |
| 18 | 18 | import { ForgeApi } from "../src/cli/api.ts"; |
| 19 | import { packNamed } from "../src/packs/catalog.ts"; | |
| 20 | 19 | import { deployPack, type Hands } from "../src/packs/deploy.ts"; |
| 21 | 20 | import { parsePack, type Pack } from "../src/packs/pack.ts"; |
| 22 | 21 | import { finishWords, OWNER, prepareOwner } from "../src/packs/up.ts"; |
| 23 | 22 | |
| 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); | |
| 26 | 41 | if (!existsSync(arg)) return `no such file: ${arg}`; |
| 27 | 42 | const parsed = parsePack(readFileSync(arg, "utf8")); |
| 28 | 43 | return parsed.ok ? parsed.value : parsed.error; |
| @@ -228,7 +243,7 @@ export async function up(pack: Pack, options: UpOptions): Promise<number> { | ||
| 228 | 243 | |
| 229 | 244 | if (import.meta.main) { |
| 230 | 245 | const [verb, which] = process.argv.slice(2); |
| 231 | const pack = which === undefined ? USAGE : load(which); | |
| 246 | const pack = which === undefined ? USAGE : await load(which); | |
| 232 | 247 | if (typeof pack === "string" || (verb !== "show" && verb !== "deploy" && verb !== "up")) { |
| 233 | 248 | console.error(typeof pack === "string" ? pack : USAGE); |
| 234 | 249 | process.exit(2); |
src/http/mcp/tools/repo_settings.ts +12 −1
| @@ -1,12 +1,14 @@ | ||
| 1 | 1 | import { updateRepoSettings } from "../../../actions/repos.ts"; |
| 2 | 2 | import { TRANSFER_SAID, transferRepository } from "../../../actions/transfer.ts"; |
| 3 | 3 | import { mirrorPacks, reportWords } from "../../../packs/mirror.ts"; |
| 4 | import { rejectedMarkedSentence } from "../../../packs/marked.ts"; | |
| 5 | import { setPackRepos } from "../../../packs/set.ts"; | |
| 4 | 6 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 5 | 7 | import { VISIBILITY_WORDS } from "../../../core/arg_words.ts"; |
| 6 | 8 | |
| 7 | 9 | export const tool: Tool = { |
| 8 | 10 | 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`", | |
| 10 | 12 | description: |
| 11 | 13 | "Read, change or move (newOwner) a repo: PATCH /api/v1/repos/:owner/:name (admin); mirrorPacks mirrors the packs' upstreams.", |
| 12 | 14 | inputSchema: { |
| @@ -17,11 +19,20 @@ export const tool: Tool = { | ||
| 17 | 19 | visibility: { type: "string", enum: ["public", "private"] }, |
| 18 | 20 | newOwner: { type: "string", maxLength: 64 }, |
| 19 | 21 | mirrorPacks: { type: "boolean" }, |
| 22 | packRepos: { type: "array", items: { type: "string", maxLength: 100 }, maxItems: 20 }, | |
| 20 | 23 | }, |
| 21 | 24 | }, |
| 22 | 25 | writes: true, |
| 23 | 26 | needsAdminScope: true, |
| 24 | 27 | 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 | } | |
| 25 | 36 | if (args["mirrorPacks"] === true) { |
| 26 | 37 | const done = await mirrorPacks(ctx.app, user); |
| 27 | 38 | 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 { | ||
| 34 | 34 | signInAct, |
| 35 | 35 | } from "../web/pages/accounts.ts"; |
| 36 | 36 | import { confirmsList, confirmYes } from "../web/pages/confirms.ts"; |
| 37 | import { packsSettingsPage, packsSettingsSave } from "../web/pages/packs-settings.ts"; | |
| 37 | 38 | import { featureAccept, featurePage, featureRun, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts"; |
| 38 | 39 | import { passkeyAdd, passkeyDelete, passkeyOptions, passkeysPage } from "../web/pages/passkeys.ts"; |
| 39 | 40 | import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts"; |
| @@ -187,6 +188,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 187 | 188 | router.add("POST", "/settings/economics", economicsSet); |
| 188 | 189 | router.add("GET", "/settings/flags", flagsPage); |
| 189 | 190 | // 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); | |
| 190 | 193 | router.add("GET", "/settings/features", featuresPage); |
| 191 | 194 | router.add("GET", "/settings/features/:slug", featurePage); |
| 192 | 195 | router.add("POST", "/settings/features/:slug", featureSave); |
src/main.ts +2
| @@ -20,6 +20,7 @@ import { createApp } from "./app.ts"; | ||
| 20 | 20 | import { readOnlyFrom } from "./http/read_only.ts"; |
| 21 | 21 | import { warmFlow } from "./system/flow_cache.ts"; |
| 22 | 22 | import { backfillCarried } from "./core/carried.ts"; |
| 23 | import { refreshCatalogQuietly } from "./packs/catalog.ts"; | |
| 23 | 24 | import { loadTrustedHosts } from "./origins/trusted-hosts.ts"; |
| 24 | 25 | import { flushFlagUses, loadFlags } from "./core/flags.ts"; |
| 25 | 26 | import { reconcile } from "./core/reconcile.ts"; |
| @@ -330,6 +331,7 @@ const app = createApp({ | ||
| 330 | 331 | timeStatements(app.db, createLog("sql")); |
| 331 | 332 | loadFlags(app.db); |
| 332 | 333 | loadTrustedHosts(app.db); |
| 334 | await refreshCatalogQuietly(app); | |
| 333 | 335 | if (readOnly) { |
| 334 | 336 | const servers = await serve(app); |
| 335 | 337 | // Only the listener runs: no reconcile, perf watcher, timers, apps, crew, |
src/packs/catalog.ts +89 −20
| @@ -1,34 +1,103 @@ | ||
| 1 | 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. | |
| 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. | |
| 5 | 11 | */ |
| 6 | 12 | |
| 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"; | |
| 9 | 18 | import { parsePack, type Pack } from "./pack.ts"; |
| 10 | 19 | |
| 11 | const ROOT = join(import.meta.dir, "..", "..", "packs"); | |
| 20 | export type Catalog = { packs: readonly Pack[]; problems: readonly string[] }; | |
| 12 | 21 | |
| 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> { | |
| 14 | 27 | const packs: Pack[] = []; |
| 15 | 28 | 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 | } | |
| 25 | 46 | } |
| 26 | 47 | packs.sort((a, b) => a.name.localeCompare(b.name)); |
| 27 | 48 | return { packs, problems }; |
| 28 | 49 | } |
| 29 | 50 | |
| 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; | |
| 33 | 55 | |
| 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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 | |
| 4 | 4 | * its upstream through the follow the forge already has |
| 5 | 5 | * (`actions/upstream_follow.ts`). The request only creates the repository and |
| 6 | 6 | * 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"; | ||
| 42 | 42 | import { entry as slowPagesEntry } from "./slowpages.ts"; |
| 43 | 43 | import { entry as maintenance } from "./maintenance.ts"; |
| 44 | 44 | import { entry as mirrorPacksEntry } from "./mirrorpacks.ts"; |
| 45 | import { entry as packReposEntry } from "./packrepos.ts"; | |
| 45 | 46 | import { modelCeilingEntry, modelGrantEntry } from "./model.ts"; |
| 46 | 47 | import { entry as orgGrants } from "./orggrants.ts"; |
| 47 | 48 | import { entry as orgTeams } from "./orgteams.ts"; |
| @@ -96,6 +97,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 96 | 97 | resilience, |
| 97 | 98 | maintenance, |
| 98 | 99 | mirrorPacksEntry, |
| 100 | packReposEntry, | |
| 99 | 101 | valves, |
| 100 | 102 | access, |
| 101 | 103 | 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 @@ | ||
| 5 | 5 | * prints. Anyone may open it; it reads the catalog, nothing of the forge's. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { catalog } from "../../packs/catalog.ts"; | |
| 8 | import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts"; | |
| 9 | import type { App } from "../../app.ts"; | |
| 9 | 10 | import type { Pack } from "../../packs/pack.ts"; |
| 10 | 11 | import { BRINGS_WORDS, deployCommands, STRANGER_WORDS } from "../../packs/words.ts"; |
| 11 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -61,8 +62,8 @@ function packScreen(ctx: Ctx, p: Pack, origin: string | undefined): Screen { | ||
| 61 | 62 | }; |
| 62 | 63 | } |
| 63 | 64 | |
| 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 |
||
| 66 | 67 | let at = 0; |
| 67 | 68 | return { |
| 68 | 69 | title: "Forge packs", |
| @@ -70,7 +71,7 @@ export function packsScreen(ctx: Ctx, origin: string | undefined): Screen { | ||
| 70 | 71 | render(area, s) { |
| 71 | 72 | const c = ctx.theme.c; |
| 72 | 73 | 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) => { | |
| 74 | 75 | const on = i === at; |
| 75 | 76 | const row = `${on ? "> " : " "}${p.title} v${p.version} ${p.apps.length} apps, ${p.crew.length} crew ${p.description}`; |
| 76 | 77 | 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 { | ||
| 79 | 80 | key(e) { |
| 80 | 81 | if (e.ctrl || e.alt) return false; |
| 81 | 82 | 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); | |
| 83 | 84 | else if (e.key === "k" || e.key === "up") at = Math.max(0, at - 1); |
| 84 | 85 | else if (e.key === "enter") { |
| 85 | const p = packs[at]; | |
| 86 | const p = catalog().packs[at]; | |
| 86 | 87 | if (p !== undefined) ctx.push(packScreen(ctx, p, origin)); |
| 87 | 88 | } else return false; |
| 88 | 89 | return true; |
| @@ -94,5 +95,5 @@ export const packsCommand = (session: Session, ctx: Ctx) => ({ | ||
| 94 | 95 | id: "packs", |
| 95 | 96 | title: "Forge packs", |
| 96 | 97 | 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)), | |
| 98 | 99 | }); |
src/web/pages/origins.ts +2
| @@ -114,6 +114,7 @@ export type AccountSection = | ||
| 114 | 114 | | "economics" |
| 115 | 115 | | "flags" |
| 116 | 116 | | "features" |
| 117 | | "packs" | |
| 117 | 118 | | "cloud"; |
| 118 | 119 | |
| 119 | 120 | /** |
| @@ -161,6 +162,7 @@ const SECTION_ROWS: readonly (readonly [ | ||
| 161 | 162 | ["This forge", "Flows", "/admin/flows", "activity", (c) => c === "adminflows", true], |
| 162 | 163 | ["This forge", "Flags", "/settings/flags", "activity", (c) => c === "flags", true], |
| 163 | 164 | ["This forge", "Features", "/settings/features", "rocket", (c) => c === "features", true], |
| 165 | ["This forge", "Packs", "/settings/packs", "rocket", (c) => c === "packs", true], | |
| 164 | 166 | ["This forge", "Backups", "/settings/backups", "download", (c) => c === "backups", true], |
| 165 | 167 | ]; |
| 166 | 168 |
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 |
||
| 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 |
||
| 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 @@ | ||
| 6 | 6 | |
| 7 | 7 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 8 | 8 | import type { Pack } from "../../packs/pack.ts"; |
| 9 | import { catalog, packNamed } from "../../packs/catalog.ts"; | |
| 9 | import { refreshCatalogQuietly } from "../../packs/catalog.ts"; | |
| 10 | 10 | import type { Ctx } from "../../http/context.ts"; |
| 11 | 11 | import { notFoundPage, pageHead } from "../chrome.ts"; |
| 12 | 12 | import { deployCommands } from "../../packs/words.ts"; |
| @@ -29,11 +29,14 @@ export const packsList = (packs: readonly Pack[]) => | ||
| 29 | 29 | }), |
| 30 | 30 | )}</ul>`; |
| 31 | 31 | |
| 32 | /** A pack pushed to its repo shows within this long. */ | |
| 33 | const CATALOG_FRESH_MS = 2_000; | |
| 34 | ||
| 32 | 35 | // GET /packs |
| 33 | export function packsPage(ctx: Ctx): Response { | |
| 36 | export async function packsPage(ctx: Ctx): Promise<Response> { | |
| 34 | 37 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 35 | 38 | const started = performance.now(); |
| 36 | const { packs } = catalog(); | |
| 39 | const { packs } = await refreshCatalogQuietly(ctx.app, CATALOG_FRESH_MS); | |
| 37 | 40 | const body = html`${pageHead("Forge packs", { |
| 38 | 41 | sub: "A whole forge to start from: real apps with their history, and a crew set up to work on them.", |
| 39 | 42 | })} |
| @@ -50,10 +53,10 @@ export function packsPage(ctx: Ctx): Response { | ||
| 50 | 53 | } |
| 51 | 54 | |
| 52 | 55 | // GET /packs/:name |
| 53 | export function packPage(ctx: Ctx): Response { | |
| 56 | export async function packPage(ctx: Ctx): Promise<Response> { | |
| 54 | 57 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 55 | 58 | 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 ?? "")); | |
| 57 | 60 | if (pack === undefined) return notFoundPage(ctx); |
| 58 | 61 | const commands = deployCommands(pack, ctx.app.config.url); |
| 59 | 62 | const body = html`${pageHead(`${pack.title} pack`, { sub: pack.description })} |
src/web/studio/home.ts +3 −1
| @@ -17,7 +17,7 @@ import { | ||
| 17 | 17 | type FrontPage, |
| 18 | 18 | type FrontPicksJson, |
| 19 | 19 | } from "../../actions/front.ts"; |
| 20 | import { catalog } from "../../packs/catalog.ts"; | |
| 20 | import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts"; | |
| 21 | 21 | import { appsRuntime, type EnvState } from "../../apps/runtime.ts"; |
| 22 | 22 | import { signupClosed } from "../../actions/users.ts"; |
| 23 | 23 | import { getSignup } from "../../settings/signup.ts"; |
| @@ -737,6 +737,8 @@ function agentPrompt(origin: string): string { | ||
| 737 | 737 | * controls that decide it. |
| 738 | 738 | */ |
| 739 | 739 | 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 |
||
| 740 | 742 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 741 | 743 | let tiles: Html[] = []; |
| 742 | 744 | 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", () => { | ||
| 37 | 37 | expect(ignored(lines(".dockerignore"), path)).toBe(false); |
| 38 | 38 | }); |
| 39 | 39 | |
| 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"); | |
| 44 | 43 | }); |
| 45 | 44 | }); |
packs/business/pack.json → test/fixtures/packs/business.json renamed
packs/games/pack.json → test/fixtures/packs/games.json renamed
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"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | import { catalog } from "../src/packs/catalog.ts"; |
| 8 | import { seedPacks } from "./helpers/packs.ts"; | |
| 8 | 9 | import { packsList } from "../src/web/pages/packs.ts"; |
| 9 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 10 | 11 | |
| @@ -15,6 +16,7 @@ let servers: Servers; | ||
| 15 | 16 | beforeAll(async () => { |
| 16 | 17 | dataDir = await mkdtemp(join(tmpdir(), "forge-keys-packs-")); |
| 17 | 18 | app = createApp({ dataDir, port: 0 }); |
| 19 | await seedPacks(app); | |
| 18 | 20 | servers = await serve(app); |
| 19 | 21 | }); |
| 20 | 22 |
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"; | ||
| 9 | 9 | import { deployPack } from "../src/packs/deploy.ts"; |
| 10 | 10 | import { parsePack } from "../src/packs/pack.ts"; |
| 11 | 11 | import { packNamed } from "../src/packs/catalog.ts"; |
| 12 | import { fixturePacks, seedPacks } from "./helpers/packs.ts"; | |
| 12 | 13 | import { deployCommands } from "../src/packs/words.ts"; |
| 13 | 14 | import { createForgeApp, forgeCommands } from "../src/tui/forge.ts"; |
| 14 | 15 | import { packLines, packsScreen } from "../src/tui/screens/packs.ts"; |
| @@ -68,7 +69,7 @@ describe("makeAgent", () => { | ||
| 68 | 69 | }); |
| 69 | 70 | |
| 70 | 71 | describe("deploy words", () => { |
| 71 | const games = packNamed("games")!; | |
| 72 | const games = fixturePacks().find((p) => p.name === "games")!; | |
| 72 | 73 | test("the clone source is the configured address, never a request's Host", () => { |
| 73 | 74 | expect(deployCommands(games, "https://f.example/")[0]).toContain("git clone https://f.example/trevato/forge.git"); |
| 74 | 75 | expect(deployCommands(games, undefined)[0]).not.toContain("http"); |
| @@ -76,6 +77,7 @@ describe("deploy words", () => { | ||
| 76 | 77 | test("the web page does not echo a forged Host", async () => { |
| 77 | 78 | const dir = await mkdtemp(join(tmpdir(), "packs-host-")); |
| 78 | 79 | const a = createApp({ dataDir: dir, port: 0 }); |
| 80 | await seedPacks(a); | |
| 79 | 81 | const servers = await serve(a); |
| 80 | 82 | try { |
| 81 | 83 | const res = await fetch(`${servers.url}/packs/games`, { headers: { host: "evil.example" } }); |
| @@ -97,6 +99,7 @@ describe("terminal screen", () => { | ||
| 97 | 99 | app = createApp({ dataDir: dir, port: 0 }); |
| 98 | 100 | const r = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); |
| 99 | 101 | if (!r.ok) throw new Error(r.error); |
| 102 | await seedPacks(app); | |
| 100 | 103 | }); |
| 101 | 104 | afterAll(async () => { |
| 102 | 105 | app.close(); |
test/packs-mirror.test.ts +9 −2
| @@ -22,6 +22,7 @@ import { serveGitCommand } from "../src/tui/ssh.ts"; | ||
| 22 | 22 | import { createForgeApp, forgeCommands } from "../src/tui/forge.ts"; |
| 23 | 23 | import { mirrorPacksScreen } from "../src/tui/screens/mirrorpacks.ts"; |
| 24 | 24 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 25 | import { seedPacks } from "./helpers/packs.ts"; | |
| 25 | 26 | import { settleApp } from "./helpers/tui.ts"; |
| 26 | 27 | import { tool } from "../src/http/mcp/tools/repo_settings.ts"; |
| 27 | 28 | import { mirrorPacks, packSources, reportWords } from "../src/packs/mirror.ts"; |
| @@ -115,9 +116,15 @@ describe("packs mirrors", () => { | ||
| 115 | 116 | ]; |
| 116 | 117 | let first = ""; |
| 117 | 118 | |
| 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 | } | |
| 119 | 126 | const names = packSources().map((s) => s.name); |
| 120 | expect(names).toContain("memos"); | |
| 127 | expect(names).toContain("hextris"); | |
| 121 | 128 | expect(names).toContain("2048"); |
| 122 | 129 | expect(packSources().every((s) => s.source.startsWith("https://"))).toBe(true); |
| 123 | 130 | }); |
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"; | ||
| 9 | 9 | import { getRepo } from "../src/core/repos.ts"; |
| 10 | 10 | import { getFollow } from "../src/core/upstream_follow.ts"; |
| 11 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | import { catalog } from "../src/packs/catalog.ts"; | |
| 12 | import { fixturePacks } from "./helpers/packs.ts"; | |
| 13 | 13 | import { deployPack } from "../src/packs/deploy.ts"; |
| 14 | 14 | import { addSubscription } from "../src/core/subscriptions.ts"; |
| 15 | 15 | import { checkUpstream } from "../src/actions/upstream_follow.ts"; |
| @@ -55,7 +55,7 @@ beforeAll(async () => { | ||
| 55 | 55 | passphrase = prepared.passphrase; |
| 56 | 56 | // 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. |
| 57 | 57 | let n = 0; |
| 58 | for (const pack of catalog().packs) | |
| 58 | for (const pack of fixturePacks()) | |
| 59 | 59 | for (const a of pack.apps) { |
| 60 | 60 | const dir = join(root, `src-${n++}`); |
| 61 | 61 | git(root, "init", "-q", "-b", n === 1 ? "master" : "main", dir); |
| @@ -78,7 +78,7 @@ afterAll(async () => { | ||
| 78 | 78 | |
| 79 | 79 | describe("the one command, against a fake source", () => { |
| 80 | 80 | 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()) { | |
| 82 | 82 | const lines = deployCommands(p, "https://f.example"); |
| 83 | 83 | expect(lines.some((l) => l.startsWith(`bun scripts/pack.ts up ${p.name}`))).toBe(true); |
| 84 | 84 | expect(lines[0]).toContain("bun install"); |
| @@ -103,7 +103,7 @@ describe("the one command, against a fake source", () => { | ||
| 103 | 103 | |
| 104 | 104 | for (const name of ["games", "business"]) { |
| 105 | 105 | 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)!; | |
| 107 | 107 | const steps = await deployPack(pack, realHands(servers.url, token, { cloneFrom: (s) => fake.get(s)! })); |
| 108 | 108 | expect(steps.filter((s) => s.state !== "done")).toEqual([]); |
| 109 | 109 | for (const a of pack.apps) { |
| @@ -130,7 +130,7 @@ describe("the one command, against a fake source", () => { | ||
| 130 | 130 | } |
| 131 | 131 | |
| 132 | 132 | test("running it again keeps what exists", async () => { |
| 133 | const pack = catalog().packs[0]!; | |
| 133 | const pack = fixturePacks()[0]!; | |
| 134 | 134 | const steps = await deployPack(pack, realHands(servers.url, token, { cloneFrom: (s) => fake.get(s)! })); |
| 135 | 135 | expect(steps.map((s) => `${s.what} ${s.state} ${s.note}`)).toEqual(steps.map((s) => `${s.what} kept already here`)); |
| 136 | 136 | }); |
| @@ -159,7 +159,7 @@ describe("the one command, against a fake source", () => { | ||
| 159 | 159 | }); |
| 160 | 160 | |
| 161 | 161 | 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]!; | |
| 163 | 163 | const hands = realHands(servers.url, token, { cloneFrom: (s) => fake.get(s)! }); |
| 164 | 164 | // First run: the follow of the first app fails after the app and its history are in. |
| 165 | 165 | const one = { ...pack, apps: [{ ...pack.apps[0]!, name: "partial-app" }], crew: [] }; |
| @@ -184,7 +184,7 @@ describe("the one command, against a fake source", () => { | ||
| 184 | 184 | }); |
| 185 | 185 | |
| 186 | 186 | test("a new upstream commit shows as drift at the next check", async () => { |
| 187 | const pack = catalog().packs[0]!; | |
| 187 | const pack = fixturePacks()[0]!; | |
| 188 | 188 | const a = pack.apps[0]!; |
| 189 | 189 | const repo = getRepo(app.db, OWNER, a.name)!; |
| 190 | 190 | const src = fake.get(a.source)!; |
| @@ -234,7 +234,7 @@ describe("up() itself", () => { | ||
| 234 | 234 | const env: Record<string, string | undefined> = { ...process.env, FORGE_SSH_PORT: "0" }; |
| 235 | 235 | for (const n of ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "FORGE_CLAUDE_CONFIG_DIR"]) delete env[n]; |
| 236 | 236 | try { |
| 237 | const pack = catalog().packs[0]!; | |
| 237 | const pack = fixturePacks()[0]!; | |
| 238 | 238 | const running = up(pack, { port, dataDir: d, cloneFrom: (s) => fake.get(s)!, say: (l) => lines.push(l), signal: stop.signal, env }); |
| 239 | 239 | let ended: number | undefined; |
| 240 | 240 | void running.then((c) => (ended = c)); |
test/packs.test.ts +38 −6
| @@ -5,7 +5,10 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | 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"; | |
| 9 | 12 | import { deployPack, type Hands } from "../src/packs/deploy.ts"; |
| 10 | 13 | import { parsePack, sourceOf } from "../src/packs/pack.ts"; |
| 11 | 14 | |
| @@ -104,12 +107,40 @@ describe("pack absorb", () => { | ||
| 104 | 107 | }); |
| 105 | 108 | }); |
| 106 | 109 | |
| 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); | |
| 110 | 126 | 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"); | |
| 113 | 144 | }); |
| 114 | 145 | }); |
| 115 | 146 | |
| @@ -158,6 +189,7 @@ describe("pages", () => { | ||
| 158 | 189 | beforeAll(async () => { |
| 159 | 190 | dataDir = await mkdtemp(join(tmpdir(), "forge-packs-")); |
| 160 | 191 | app = createApp({ dataDir, port: 0 }); |
| 192 | await seedPacks(app); | |
| 161 | 193 | servers = await serve(app); |
| 162 | 194 | }); |
| 163 | 195 | afterAll(async () => { |
test/page-head.test.ts +2
| @@ -8,6 +8,7 @@ import { getRepo } from "../src/core/repos.ts"; | ||
| 8 | 8 | import { createIssue } from "../src/core/issues.ts"; |
| 9 | 9 | import { createApp as createAppRecord } from "../src/core/apps.ts"; |
| 10 | 10 | import { createUser } from "../src/core/users.ts"; |
| 11 | import { seedPacks } from "./helpers/packs.ts"; | |
| 11 | 12 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 13 | import { appLine, plainDescription, repoLine } from "../src/web/describe.ts"; |
| 13 | 14 | |
| @@ -33,6 +34,7 @@ describe("every public page names itself for a link preview", () => { | ||
| 33 | 34 | adminIfFirst: true, |
| 34 | 35 | }); |
| 35 | 36 | if (!u.ok) throw new Error(u.error); |
| 37 | await seedPacks(app); | |
| 36 | 38 | // No description at all, and one that is scaffold jargon. |
| 37 | 39 | for (const name of ["bare", "widget"]) { |
| 38 | 40 | 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", () => { | ||
| 49 | 49 | ["explore", "Explore", "on the web"], |
| 50 | 50 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 51 | 51 | ["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"], | |
| 52 | 53 | ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"], |
| 53 | 54 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 54 | 55 | ["profile", "Open a profile", "username or org"], |
test/public-head.test.ts +2
| @@ -9,6 +9,7 @@ import { createIssue } from "../src/core/issues.ts"; | ||
| 9 | 9 | import { createApp as createAppRecord } from "../src/core/apps.ts"; |
| 10 | 10 | import { createUser } from "../src/core/users.ts"; |
| 11 | 11 | import { catalog } from "../src/packs/catalog.ts"; |
| 12 | import { seedPacks } from "./helpers/packs.ts"; | |
| 12 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 13 | 14 | |
| 14 | 15 | /** The content of the first tag matching, or undefined when there is none. */ |
| @@ -90,6 +91,7 @@ describe("every public page names itself", () => { | ||
| 90 | 91 | } |
| 91 | 92 | const rec = createAppRecord(app.db, { repoId: getRepo(app.db, "alice", "widget")!.id, slug: "widget", access: "public" }); |
| 92 | 93 | if (!rec.ok) throw new Error(rec.error); |
| 94 | await seedPacks(app); | |
| 93 | 95 | const pack = catalog().packs[0]; |
| 94 | 96 | if (!pack) throw new Error("the catalog ships no pack to walk"); |
| 95 | 97 | // Walked as a stranger. Left out, on purpose: redirects to sign in (/map, |
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.