Public: anyone can read this; the crew can change it.
Forge packs: deploy a premade forge, such as Games or Business, and start contributing to public apps #9528
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9428-forge-packs-deploy-a-premade-forge-such → main 5 commits fork 2 threads · 2 unresolved
25 files
changed +949
−6
AGENTS.md +1 −1
| @@ -8,7 +8,7 @@ When you write an issue, end it with a `Files:` line naming the files the work t | ||
| 8 | 8 | |
| 9 | 9 | A new `forge` command is a new file in `src/cli/commands/` (exporting `command`) plus one line in its `index.ts`. |
| 10 | 10 | |
| 11 | A new terminal screen offered only to site admins exports its palette `entry` from its own module and adds one line to `ENTRIES` in `src/tui/screens/entries.ts`; `after` names the commands it follows. Entries offered to everyone, and entries gated by a helper (`mayCapacity`, `mayManageFlags`, `canSeeMemory`, `telemetryReader` and the like) rather than by `isAdmin`, stay in `forgeCommands` in `src/tui/forge.ts`. | |
| 11 | A new terminal screen offered only to site admins exports its palette `entry` from its own module and adds one line to `ENTRIES` in `src/tui/screens/entries.ts`; `after` names the commands it follows. Entries offered to everyone (the packs screen, `src/tui/screens/packs.ts`, is one), and entries gated by a helper (`mayCapacity`, `mayManageFlags`, `canSeeMemory`, `telemetryReader` and the like) rather than by `isAdmin`, stay in `forgeCommands` in `src/tui/forge.ts`. | |
| 12 | 12 | |
| 13 | 13 | A new read of a table that grows (`event`, `issue`, `run`, `comment`) names the index it walks, in the migration that adds it, and a test shows it from the query plan (`test/hot-reads-indexed.test.ts` is the pattern): no `SCAN`, no `TEMP B-TREE`. Judge by plan or row count, never the clock. A read that stays slow with the best index is a summary table, kept as the rows change, before it is a worker. |
| 14 | 14 |
docs/design/audit.md +1
| @@ -64,6 +64,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 64 | 64 | - `/talk` — `pages/talk.ts` · the person's one conversation with the forge: history, composer, Confirm and Cancel; replies stream in over `/talk/stream` · `/settings/confirms` (the same yes, as a list), the home tell box (one line to an app's crew) · none seen; styles in `docs/design/areas/talk.md`. |
| 65 | 65 | - `/notes` — `pages/notes.ts` · what the person wrote, newest first, with the planner's verdict and live state in one line, and one tap to overrule it · the home tell box (Notes button), the user menu, the palette · none seen; styles in `docs/design/areas/notes.md`. |
| 66 | 66 | - `/pipeline` — `pages/pipeline.ts` · the four steps a change follows (check, review, land, live), for a newcomer · a pull page's step line, `/flow` · none seen. |
| 67 | - `/packs` — `pages/packs.ts` · the premade forges this forge ships, what each brings, how to deploy it and how a stranger helps build its apps · the user menu, the signed-out menu, the palette · none seen; uses the shared card, list and button components. | |
| 67 | 68 | - `/map` — `pages/map.ts` (1,806 lines) · the dependency map of work across the forge · `/board`, issue dependency lists, `/:owner/:name/map` · colours 3, sizes 12: the largest private vocabulary. |
| 68 | 69 | - `/audit` — `pages/audit.ts` · every app's contract, who can do what · the app page's one-line policy · none seen. Names the audit of apps, not this document. |
| 69 | 70 | - `/repos` — `pages/my-repos.ts` · the repositories you may write, from the account menu · `/` (your apps), `/:owner` (a profile's repositories) · none seen. |
docs/features/packs.md added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | # Forge packs: a premade forge to deploy (`/packs`) | |
| 2 | ||
| 3 | A pack is a `pack.json` in a repository (`src/packs/pack.ts` holds the format, version 1): a name, title, semver version, description, the **apps** it brings (each an https address of a public git repository, with a name and description) and the **crew** it starts with (username, roles of planner/builder/reviewer, a brief, optionally a model and a per-run budget). Anyone can publish one. A pack may not carry data, secrets or variables: any key that names one (`secret`, `env`, `token`, `data`, `key`…) at any depth is refused, not ignored. | |
| 4 | ||
| 5 | Two ship in `packs/`: **games** (2048, hextris, react-tetris) and **business** (memos, kanboard), each with a crew. | |
| 6 | ||
| 7 | - **See one.** `/packs` lists them, `/packs/<name>` says what it brings, the deploy commands, and how a stranger helps build its apps (get a guest account or use their own forge; a guest's pull waits for a person, and paths touching secrets, credentials, permissions or release machinery are named on it — `docs/ROLES.md`). Reached from the user menu, the signed-out menu and the palette. `bun scripts/pack.ts show games` prints the same in a terminal, and the SSH terminal has a **Forge packs** screen (palette, `src/tui/screens/packs.ts`): the list, a pack's apps and crew, the deploy commands and what happens to a stranger's pull. The words are shared in `src/packs/words.ts`. | |
| 8 | - **Deploy one.** Boot a forge (`bun run src/main.ts`), make an account and token, then `FORGE_URL=… FORGE_TOKEN=forge_… bun scripts/pack.ts deploy games`. Each app is made, the source's whole history (branches and tags) is pushed in, and the crew is added. Run it again to finish a partial deploy: what exists is kept. The model credential is yours to add in Settings. | |
| 9 | - **Nightly.** `scripts/pack-compat.ts` boots a clean forge per pack, deploys it with the real sources and fails if any step or app page fails (job `pack-compat`, `src/nightly/jobs.ts`). | |
| 10 | - Code: `src/packs/`, `src/web/pages/packs.ts`, `scripts/pack.ts`. Tests: `test/packs.test.ts`, `test/packs-hands.test.ts` (the git credential is scoped to the forge and never sent to a pack's source; only 409 means "exists"; the terminal screen). | |
| 11 | ||
| 12 | Not done yet: a hosted "deploy this pack" press, a stable follow link from each app back to its source, the guest sign-up gate and the crew filtering guest pulls on automode (owner decisions on #9428). |
packs/business/pack.json added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | { | |
| 2 | "pack": 1, | |
| 3 | "name": "business", | |
| 4 | "title": "Business", | |
| 5 | "version": "1.0.0", | |
| 6 | "description": "The everyday tools of a small business: notes and a task board, two open-source apps with their history, and a crew that plans, builds and reviews changes you ask for.", | |
| 7 | "apps": [ | |
| 8 | { "name": "memos", "description": "Quick notes and a shared journal for a team.", "source": "https://github.com/usememos/memos" }, | |
| 9 | { "name": "kanboard", "description": "A simple board for tasks and projects.", "source": "https://github.com/kanboard/kanboard" } | |
| 10 | ], | |
| 11 | "crew": [ | |
| 12 | { "username": "office-planner", "roles": ["planner"], "brief": "You turn what the business asks for into small, clear steps. Prefer the change that saves someone time this week." }, | |
| 13 | { "username": "office-builder", "roles": ["builder"], "brief": "You make the changes the planner lays out, in small steps, with tests where the app has them." }, | |
| 14 | { "username": "office-reviewer", "roles": ["reviewer"], "brief": "You review changes for a business that keeps customer information. Refuse anything that touches secrets or leaks data." } | |
| 15 | ] | |
| 16 | } |
packs/games/pack.json added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | { | |
| 2 | "pack": 1, | |
| 3 | "name": "games", | |
| 4 | "title": "Games", | |
| 5 | "version": "1.0.0", | |
| 6 | "description": "A small arcade you can play today and change tomorrow: three well-loved open-source games, each with its history, and a crew that keeps them tidy.", | |
| 7 | "apps": [ | |
| 8 | { "name": "2048", "description": "Slide tiles and merge them to reach 2048.", "source": "https://github.com/gabrielecirulli/2048" }, | |
| 9 | { "name": "hextris", "description": "A fast puzzle on a spinning hexagon.", "source": "https://github.com/Hextris/hextris" }, | |
| 10 | { "name": "react-tetris", "description": "Tetris in the browser, with sound.", "source": "https://github.com/chvin/react-tetris" } | |
| 11 | ], | |
| 12 | "crew": [ | |
| 13 | { "username": "arcade-builder", "roles": ["builder"], "brief": "You build and fix small browser games. Keep them fast, playable on a phone and fun. Change one thing at a time." }, | |
| 14 | { "username": "arcade-reviewer", "roles": ["reviewer"], "brief": "You review changes to games. Check that the game still plays, nothing gets slower, and the change is small enough to follow." } | |
| 15 | ] | |
| 16 | } |
scripts/pack-compat.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * `bun scripts/pack-compat.ts` — does every shipped pack deploy on a clean | |
| 3 | * forge? For each pack it boots a new forge on an empty data directory, makes | |
| 4 | * an account and a token, deploys the pack as `scripts/pack.ts` does (so the | |
| 5 | * apps' real sources are cloned over the network) and checks every step | |
| 6 | * succeeded and each app's page answers. Exit 1 and the failing steps | |
| 7 | * otherwise. Run nightly (`src/nightly/jobs.ts`, job `pack-compat`). | |
| 8 | */ | |
| 9 | ||
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp } from "../src/app.ts"; | |
| 14 | import { createToken } from "../src/core/tokens.ts"; | |
| 15 | import { createUser } from "../src/core/users.ts"; | |
| 16 | import { serve } from "../src/http/server.ts"; | |
| 17 | import { catalog } from "../src/packs/catalog.ts"; | |
| 18 | import { deployPack } from "../src/packs/deploy.ts"; | |
| 19 | import { realHands } from "./pack.ts"; | |
| 20 | ||
| 21 | const { packs, problems } = catalog(); | |
| 22 | let failed = problems.length; | |
| 23 | for (const p of problems) console.error(`✗ ${p}`); | |
| 24 | ||
| 25 | for (const pack of packs) { | |
| 26 | const dataDir = await mkdtemp(join(tmpdir(), `pack-compat-${pack.name}-`)); | |
| 27 | const app = createApp({ dataDir, port: 0 }); | |
| 28 | const servers = await serve(app); | |
| 29 | try { | |
| 30 | const made = await createUser(app.db, { | |
| 31 | origin: "seed", | |
| 32 | trusted: true, | |
| 33 | allowReserved: true, | |
| 34 | username: "owner", | |
| 35 | email: "[email protected]", | |
| 36 | password: "a-good-passphrase", | |
| 37 | adminIfFirst: true, | |
| 38 | }); | |
| 39 | if (!made.ok) throw new Error(made.error); | |
| 40 | const token = createToken(app.db, made.value.id, "pack-compat", "rw").token; | |
| 41 | const steps = await deployPack(pack, realHands(servers.url, token)); | |
| 42 | for (const s of steps) { | |
| 43 | const ok = s.state === "done"; | |
| 44 | if (!ok) failed++; | |
| 45 | console.log(`${ok ? "✓" : "✗"} ${pack.name}: ${s.what}${s.note ? ` — ${s.note}` : ""}`); | |
| 46 | } | |
| 47 | for (const a of pack.apps) { | |
| 48 | const res = await fetch(`${servers.url}/owner/${a.name}`); | |
| 49 | await res.arrayBuffer(); | |
| 50 | if (res.status !== 200) { | |
| 51 | failed++; | |
| 52 | console.log(`✗ ${pack.name}: /owner/${a.name} answered ${res.status}`); | |
| 53 | } | |
| 54 | } | |
| 55 | } finally { | |
| 56 | servers.stop(); | |
| 57 | app.close(); | |
| 58 | await rm(dataDir, { recursive: true, force: true }); | |
| 59 | } | |
| 60 | } | |
| 61 | process.exit(failed === 0 ? 0 : 1); |
scripts/pack.ts added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | /** | |
| 2 | * `bun scripts/pack.ts <show|deploy> <pack>` — a forge pack on your machine. | |
| 3 | * | |
| 4 | * bun scripts/pack.ts show games | |
| 5 | * FORGE_URL=http://localhost:3000 FORGE_TOKEN=forge_… bun scripts/pack.ts deploy games | |
| 6 | * | |
| 7 | * `<pack>` is a shipped pack's name, or the path of a pack.json. `deploy` makes | |
| 8 | * each app in the forge at FORGE_URL (the token's owner owns them), copies the | |
| 9 | * source's whole history in, and adds the crew. It brings no data, secret or | |
| 10 | * variable, and leaves the model credential to you. Run it again to finish a | |
| 11 | * partial deploy: what exists is kept. Docs: docs/features/packs.md. | |
| 12 | */ | |
| 13 | ||
| 14 | import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; | |
| 15 | import { tmpdir } from "node:os"; | |
| 16 | import { join } from "node:path"; | |
| 17 | import { ForgeApi } from "../src/cli/api.ts"; | |
| 18 | import { packNamed } from "../src/packs/catalog.ts"; | |
| 19 | import { deployPack, type Hands } from "../src/packs/deploy.ts"; | |
| 20 | import { parsePack, type Pack } from "../src/packs/pack.ts"; | |
| 21 | ||
| 22 | function load(arg: string): Pack | string { | |
| 23 | if (/^[a-z][a-z0-9-]*$/.test(arg) && !existsSync(arg)) return packNamed(arg) ?? `no pack named ${arg}`; | |
| 24 | if (!existsSync(arg)) return `no such file: ${arg}`; | |
| 25 | const parsed = parsePack(readFileSync(arg, "utf8")); | |
| 26 | return parsed.ok ? parsed.value : parsed.error; | |
| 27 | } | |
| 28 | ||
| 29 | const baseEnv = (): Record<string, string> => ({ | |
| 30 | PATH: process.env.PATH ?? "/usr/bin:/bin", | |
| 31 | HOME: tmpdir(), | |
| 32 | GIT_CONFIG_GLOBAL: "/dev/null", | |
| 33 | GIT_CONFIG_SYSTEM: "/dev/null", | |
| 34 | GIT_TERMINAL_PROMPT: "0", | |
| 35 | }); | |
| 36 | ||
| 37 | /** The environment for cloning a pack's source: a stranger's host, so no credential at all. */ | |
| 38 | export const cloneEnv = (): Record<string, string> => baseEnv(); | |
| 39 | ||
| 40 | /** | |
| 41 | * The environment for the push: the credential is scoped to the forge's own address | |
| 42 | * (`http.<url>/.extraHeader`), so git sends it nowhere else. It rides in the | |
| 43 | * environment, never in an argument another process could list. | |
| 44 | */ | |
| 45 | export const pushEnv = (url: string, token: string): Record<string, string> => ({ | |
| 46 | ...baseEnv(), | |
| 47 | GIT_CONFIG_COUNT: "1", | |
| 48 | GIT_CONFIG_KEY_0: `http.${url.replace(/\/+$/, "")}/.extraHeader`, | |
| 49 | GIT_CONFIG_VALUE_0: `Authorization: Basic ${btoa(`x:${token}`)}`, | |
| 50 | }); | |
| 51 | ||
| 52 | async function git(args: string[], env: Record<string, string>, cwd?: string): Promise<string | null> { | |
| 53 | const p = Bun.spawn(["git", ...args], { env, ...(cwd === undefined ? {} : { cwd }), stdout: "ignore", stderr: "pipe" }); | |
| 54 | const [code, errText] = await Promise.all([p.exited, new Response(p.stderr).text()]); | |
| 55 | return code === 0 ? null : errText.trim().split("\n").at(-1) ?? "git failed"; | |
| 56 | } | |
| 57 | ||
| 58 | export function realHands(url: string, token: string): Hands { | |
| 59 | const api = new ForgeApi(url, token); | |
| 60 | return { | |
| 61 | async makeApp(name, description) { | |
| 62 | const made = await api.newApp({ name, description, visibility: "public" }); | |
| 63 | if (made.ok) return { ok: true, owner: made.value.pin.owner }; | |
| 64 | const e = made.error; | |
| 65 | return { ok: false, exists: e.status === 409, why: e.message }; | |
| 66 | }, | |
| 67 | async carryHistory(source, owner, name, branch) { | |
| 68 | const dir = mkdtempSync(join(tmpdir(), "pack-")); | |
| 69 | try { | |
| 70 | const bare = join(dir, "src.git"); | |
| 71 | // Source history is fetched without hooks or templates and pushed as plain branches and tags. | |
| 72 | const cloned = await git(["clone", "--bare", "--quiet", ...(branch === undefined ? [] : ["--branch", branch]), source, bare], cloneEnv()); | |
| 73 | if (cloned !== null) return { ok: false, why: cloned }; | |
| 74 | const pushed = await git( | |
| 75 | ["push", "--quiet", "--force", `${url}/${owner}/${name}.git`, "refs/heads/*:refs/heads/*", "refs/tags/*:refs/tags/*"], | |
| 76 | pushEnv(url, token), | |
| 77 | bare, | |
| 78 | ); | |
| 79 | return pushed === null ? { ok: true } : { ok: false, why: pushed }; | |
| 80 | } finally { | |
outdated
422 is a validation failure, not 'exists'. Treating it as kept hides a missing crew member. Only 409 is 'exists'. |
||
| 81 | rmSync(dir, { recursive: true, force: true }); | |
| 82 | } | |
| 83 | }, | |
| 84 | async makeAgent(a) { | |
| 85 | const r = await fetch(`${url}/api/v1/agents`, { | |
| 86 | method: "POST", | |
| 87 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 88 | body: JSON.stringify({ username: a.username, roles: a.roles, brief: a.brief, ...(a.model ? { model: a.model } : {}), ...(a.budgetUsd !== undefined ? { budgetUsd: a.budgetUsd } : {}) }), | |
| 89 | }).catch(() => null); | |
| 90 | if (r === null) return { ok: false, exists: false, why: `cannot reach ${url}` }; | |
| 91 | if (r.ok) return { ok: true }; | |
| 92 | return { ok: false, exists: r.status === 409, why: `the forge answered ${r.status}` }; | |
| 93 | }, | |
| 94 | }; | |
| 95 | } | |
| 96 | ||
| 97 | if (import.meta.main) { | |
| 98 | const [verb, which] = process.argv.slice(2); | |
| 99 | const pack = which === undefined ? "usage: bun scripts/pack.ts <show|deploy> <pack>" : load(which); | |
| 100 | if (typeof pack === "string" || (verb !== "show" && verb !== "deploy")) { | |
| 101 | console.error(typeof pack === "string" ? pack : "usage: bun scripts/pack.ts <show|deploy> <pack>"); | |
| 102 | process.exit(2); | |
| 103 | } | |
| 104 | if (verb === "show") { | |
| 105 | console.log(`${pack.title} ${pack.version}\n${pack.description}\n`); | |
| 106 | for (const a of pack.apps) console.log(` app ${a.name.padEnd(14)} ${a.source}`); | |
| 107 | for (const c of pack.crew) console.log(` crew ${c.username.padEnd(14)} ${c.roles.join(", ")}`); | |
| 108 | process.exit(0); | |
| 109 | } | |
| 110 | const url = (process.env.FORGE_URL ?? "").replace(/\/+$/, ""); | |
| 111 | const token = process.env.FORGE_TOKEN ?? ""; | |
| 112 | if (url === "" || token === "") { | |
| 113 | console.error("deploy needs FORGE_URL and FORGE_TOKEN: the forge to fill and a token of the account that will own the apps"); | |
| 114 | process.exit(2); | |
| 115 | } | |
| 116 | const steps = await deployPack(pack, realHands(url, token)); | |
| 117 | for (const s of steps) console.log(`${s.state === "done" ? "✓" : s.state === "kept" ? "·" : "✗"} ${s.what}${s.note ? ` — ${s.note}` : ""}`); | |
| 118 | const failed = steps.some((s) => s.state === "failed"); | |
| 119 | console.log(failed ? "\nSome steps failed; run it again to finish." : `\n${pack.title} is deployed. Add your model credential in Settings to start the crew.`); | |
| 120 | process.exit(failed ? 1 : 0); | |
| 121 | } |
src/core/reserved.ts +1
| @@ -70,6 +70,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 70 | 70 | "events", |
| 71 | 71 | "bring", |
| 72 | 72 | "explore", |
| 73 | "packs", | |
| 73 | 74 | "family", |
| 74 | 75 | "favicon.ico", |
| 75 | 76 | "fleet", |
src/http/server.ts +4
| @@ -268,6 +268,7 @@ import { liveChipsFragment, liveMovingFragment } from "../web/studio/living.ts"; | ||
| 268 | 268 | import { HOME_REGIONS } from "../web/studio/home_last.ts"; |
| 269 | 269 | import { serviceWorker } from "../web/sw.ts"; |
| 270 | 270 | import { explorePage } from "../web/studio/explore.ts"; |
| 271 | import { packPage, packsPage } from "../web/pages/packs.ts"; | |
| 271 | 272 | import { |
| 272 | 273 | makeItYoursPage, |
| 273 | 274 | makeItYoursSubmit, |
| @@ -762,6 +763,9 @@ export function buildRouter(): Router<Handler> { | ||
| 762 | 763 | router.add("GET", "/front", studioDoor); |
| 763 | 764 | // Apps and people ranked by real numbers, narrowed in one line (#607). |
| 764 | 765 | router.add("GET", "/explore", explorePage); |
| 766 | // Forge packs: premade forges to deploy, and the way to help build their apps. | |
| 767 | router.add("GET", "/packs", packsPage); | |
| 768 | router.add("GET", "/packs/:name", packPage); | |
| 765 | 769 | router.add("POST", "/front", frontUpdate); |
| 766 | 770 | router.add("POST", "/home/archive", homeArchive); |
| 767 | 771 | router.add("POST", "/flow/keeper/answer", keeperAnswer); |
src/nightly/jobs.ts +14 −1
| @@ -17,7 +17,7 @@ export interface Step { | ||
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | export interface Job { |
| 20 | readonly name: "outside-gate" | "flake-hunt"; | |
| 20 | readonly name: "outside-gate" | "flake-hunt" | "pack-compat"; | |
| 21 | 21 | readonly slot: Slot; |
| 22 | 22 | readonly steps: readonly Step[]; |
| 23 | 23 | /** After the steps: the file whose quarantined edit is proposed as a pull. */ |
| @@ -72,4 +72,17 @@ export const JOBS: readonly Job[] = [ | ||
| 72 | 72 | }, |
| 73 | 73 | ], |
| 74 | 74 | }, |
| 75 | { | |
| 76 | name: "pack-compat", | |
| 77 | slot: { hour: 4, minute: 37 }, | |
| 78 | steps: [ | |
| 79 | install, | |
| 80 | { | |
| 81 | label: "deploy each pack on a clean forge", | |
| 82 | command: "bun scripts/pack-compat.ts", | |
| 83 | timeoutMs: 30 * 60_000, | |
| 84 | reports: false, | |
| 85 | }, | |
| 86 | ], | |
| 87 | }, | |
| 75 | 88 | ]; |
src/packs/catalog.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | /** | |
| 2 | * The packs this forge ships: every `packs/<name>/pack.json` in the | |
| 3 | * repository, parsed once. A file that fails to parse is left out and named | |
| 4 | * in `problems`, which a test keeps empty, so a bad pack never reaches a page. | |
| 5 | */ | |
| 6 | ||
| 7 | import { existsSync, readdirSync, readFileSync } from "node:fs"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { parsePack, type Pack } from "./pack.ts"; | |
| 10 | ||
| 11 | const ROOT = join(import.meta.dir, "..", "..", "packs"); | |
| 12 | ||
| 13 | export function loadCatalog(root = ROOT): { packs: readonly Pack[]; problems: readonly string[] } { | |
| 14 | const packs: Pack[] = []; | |
| 15 | const problems: string[] = []; | |
| 16 | if (!existsSync(root)) return { packs, problems }; | |
| 17 | for (const dir of readdirSync(root, { withFileTypes: true })) { | |
| 18 | if (!dir.isDirectory()) continue; | |
| 19 | const file = join(root, dir.name, "pack.json"); | |
| 20 | if (!existsSync(file)) continue; | |
| 21 | const parsed = parsePack(readFileSync(file, "utf8")); | |
| 22 | if (!parsed.ok) problems.push(`${dir.name}: ${parsed.error}`); | |
| 23 | else if (parsed.value.name !== dir.name) problems.push(`${dir.name}: name must match the directory`); | |
| 24 | else packs.push(parsed.value); | |
| 25 | } | |
| 26 | packs.sort((a, b) => a.name.localeCompare(b.name)); | |
| 27 | return { packs, problems }; | |
| 28 | } | |
| 29 | ||
| 30 | let cached: ReturnType<typeof loadCatalog> | undefined; | |
| 31 | /** The shipped catalog, read once per process. */ | |
| 32 | export const catalog = (): ReturnType<typeof loadCatalog> => (cached ??= loadCatalog()); | |
| 33 | ||
| 34 | export const packNamed = (name: string): Pack | undefined => catalog().packs.find((p) => p.name === name); |
src/packs/deploy.ts added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | /** | |
| 2 | * Deploy a pack into a running forge: for each app, make it, then carry the | |
| 3 | * source's whole history into it; then add the crew. The doing is handed in | |
| 4 | * (`Hands`), so this module is the order and the reporting, and a test can | |
| 5 | * watch it with fakes. An app that fails is reported and the rest go on; | |
| 6 | * running it again skips what already exists, so a half-deployed pack is | |
| 7 | * finished by deploying it again. No data, secret or variable is read from the | |
| 8 | * source, and the model credential is left for the person to supply. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Pack } from "./pack.ts"; | |
| 12 | ||
| 13 | export interface Hands { | |
| 14 | /** Make an app; `exists` when the forge already has one of that name. */ | |
| 15 | makeApp(name: string, description: string): Promise<{ ok: true; owner: string } | { ok: false; exists: boolean; why: string }>; | |
| 16 | /** Copy every branch and tag of `source` into the app, history whole. */ | |
| 17 | carryHistory(source: string, owner: string, name: string, branch: string | undefined): Promise<{ ok: true } | { ok: false; why: string }>; | |
| 18 | makeAgent(a: Pack["crew"][number]): Promise<{ ok: true } | { ok: false; exists: boolean; why: string }>; | |
| 19 | } | |
| 20 | ||
| 21 | export interface Step { | |
| 22 | readonly what: string; | |
| 23 | readonly state: "done" | "kept" | "failed"; | |
| 24 | readonly note?: string; | |
| 25 | } | |
| 26 | ||
| 27 | export async function deployPack(pack: Pack, hands: Hands): Promise<readonly Step[]> { | |
| 28 | const steps: Step[] = []; | |
| 29 | for (const app of pack.apps) { | |
| 30 | const made = await hands.makeApp(app.name, `${app.description} (from ${app.source})`); | |
| 31 | if (!made.ok) { | |
| 32 | steps.push( | |
| 33 | made.exists | |
| 34 | ? { what: `app ${app.name}`, state: "kept", note: "already here" } | |
| 35 | : { what: `app ${app.name}`, state: "failed", note: made.why }, | |
| 36 | ); | |
| 37 | continue; | |
| 38 | } | |
| 39 | const carried = await hands.carryHistory(app.source, made.owner, app.name, app.branch); | |
| 40 | steps.push( | |
| 41 | carried.ok | |
| 42 | ? { what: `app ${app.name}`, state: "done", note: `history from ${app.source}` } | |
| 43 | : { what: `app ${app.name}`, state: "failed", note: `made, history not carried: ${carried.why}` }, | |
| 44 | ); | |
| 45 | } | |
| 46 | for (const member of pack.crew) { | |
| 47 | const made = await hands.makeAgent(member); | |
| 48 | steps.push( | |
| 49 | made.ok | |
| 50 | ? { what: `crew ${member.username}`, state: "done", note: member.roles.join(", ") } | |
| 51 | : made.exists | |
| 52 | ? { what: `crew ${member.username}`, state: "kept", note: "already here" } | |
| 53 | : { what: `crew ${member.username}`, state: "failed", note: made.why }, | |
| 54 | ); | |
| 55 | } | |
| 56 | return steps; | |
| 57 | } |
src/packs/pack.ts added +155
| @@ -0,0 +1,155 @@ | ||
| 1 | /** | |
| 2 | * A forge pack: a plain, versioned definition of a forge to start from — the | |
| 3 | * apps it brings (by public source), the crew it starts with, and a | |
| 4 | * description. Anyone can publish one as a `pack.json` in a repository. Pure: | |
| 5 | * `parsePack` reads text and returns a pack or the one sentence that says what | |
| 6 | * is wrong. A pack carries no data, secret or variable: any key that looks | |
| 7 | * like one is refused, never ignored, so a published pack cannot ask for them. | |
| 8 | */ | |
| 9 | ||
| 10 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 11 | import { CREW_ROLES, type CrewRole } from "../core/agents.ts"; | |
| 12 | ||
| 13 | export const PACK_FORMAT = 1; | |
| 14 | const NAME = /^[a-z][a-z0-9-]{0,39}$/; | |
| 15 | const APP_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/; | |
| 16 | const USERNAME = /^[a-z][a-z0-9-]{0,31}$/; | |
| 17 | const VERSION = /^\d{1,4}\.\d{1,4}\.\d{1,4}$/; | |
| 18 | const REPO_PART = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; | |
| 19 | const MAX_APPS = 20; | |
| 20 | const MAX_CREW = 10; | |
| 21 | const MAX_TEXT = 2_000; | |
| 22 | /** Keys a pack may never carry, at any depth: what a pack brings is code and crew only. */ | |
| 23 | const FORBIDDEN = /secret|password|token|credential|env|variable|data|key/i; | |
| 24 | ||
| 25 | export interface PackApp { | |
| 26 | readonly name: string; | |
| 27 | readonly description: string; | |
| 28 | /** An https address of a public git repository, no credentials in it. */ | |
| 29 | readonly source: string; | |
| 30 | readonly branch?: string; | |
| 31 | } | |
| 32 | ||
| 33 | export interface PackCrew { | |
| 34 | readonly username: string; | |
| 35 | readonly roles: readonly CrewRole[]; | |
| 36 | readonly brief: string; | |
| 37 | readonly model?: string; | |
| 38 | readonly budgetUsd?: number; | |
| 39 | } | |
| 40 | ||
| 41 | export interface Pack { | |
| 42 | readonly format: typeof PACK_FORMAT; | |
| 43 | readonly name: string; | |
| 44 | readonly title: string; | |
| 45 | readonly version: string; | |
| 46 | readonly description: string; | |
| 47 | readonly apps: readonly PackApp[]; | |
| 48 | readonly crew: readonly PackCrew[]; | |
| 49 | } | |
| 50 | ||
| 51 | const text = (v: unknown, max = MAX_TEXT): string | undefined => | |
| 52 | typeof v === "string" && v.trim() !== "" && v.length <= max && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v) | |
| 53 | ? v.trim() | |
| 54 | : undefined; | |
| 55 | ||
| 56 | /** The source's address if it names a public https repository, else undefined. */ | |
| 57 | export function sourceOf(v: unknown): string | undefined { | |
| 58 | const raw = text(v, 300); | |
| 59 | const url = raw === undefined ? null : URL.parse(raw); | |
| 60 | if (url === null || url.protocol !== "https:" || url.username !== "" || url.password !== "") return undefined; | |
| 61 | if (url.search !== "" || url.hash !== "") return undefined; | |
| 62 | const parts = url.pathname.split("/").filter((s) => s !== ""); | |
| 63 | if (parts.length !== 2) return undefined; | |
| 64 | const repo = parts[1]!.replace(/\.git$/, ""); | |
| 65 | if (!REPO_PART.test(parts[0]!) || !REPO_PART.test(repo)) return undefined; | |
| 66 | return `https://${url.host.toLowerCase()}/${parts[0]}/${repo}`; | |
| 67 | } | |
| 68 | ||
| 69 | /** The first forbidden key anywhere in `v`, as a path, or undefined. */ | |
| 70 | function forbiddenKey(v: unknown, path = ""): string | undefined { | |
| 71 | if (Array.isArray(v)) { | |
| 72 | for (const [i, item] of v.entries()) { | |
| 73 | const hit = forbiddenKey(item, `${path}[${i}]`); | |
| 74 | if (hit !== undefined) return hit; | |
| 75 | } | |
| 76 | } else if (v !== null && typeof v === "object") { | |
| 77 | for (const [k, item] of Object.entries(v)) { | |
| 78 | if (FORBIDDEN.test(k)) return path === "" ? k : `${path}.${k}`; | |
| 79 | const hit = forbiddenKey(item, path === "" ? k : `${path}.${k}`); | |
| 80 | if (hit !== undefined) return hit; | |
| 81 | } | |
| 82 | } | |
| 83 | return undefined; | |
| 84 | } | |
| 85 | ||
| 86 | const bad = (m: string): Result<Pack, string> => err(`pack: ${m}`); | |
| 87 | ||
| 88 | /** Read a pack's text. Every field is checked; nothing unknown rides along. */ | |
| 89 | export function parsePack(raw: string): Result<Pack, string> { | |
| 90 | let v: unknown; | |
| 91 | try { | |
| 92 | v = JSON.parse(raw); | |
| 93 | } catch { | |
| 94 | return bad("not JSON"); | |
| 95 | } | |
| 96 | if (v === null || typeof v !== "object" || Array.isArray(v)) return bad("expected an object"); | |
| 97 | const o = v as Record<string, unknown>; | |
| 98 | const hit = forbiddenKey(o); | |
| 99 | if (hit !== undefined) | |
| 100 | return bad(`"${hit}" is not allowed: a pack brings no data, secrets or variables`); | |
| 101 | if (o.pack !== PACK_FORMAT) return bad(`"pack" must be ${PACK_FORMAT}`); | |
| 102 | const name = typeof o.name === "string" && NAME.test(o.name) ? o.name : undefined; | |
| 103 | if (name === undefined) return bad(`"name" must be lowercase letters, digits and dashes`); | |
| 104 | const title = text(o.title, 80); | |
| 105 | if (title === undefined) return bad(`"title" is missing`); | |
| 106 | const version = typeof o.version === "string" && VERSION.test(o.version) ? o.version : undefined; | |
| 107 | if (version === undefined) return bad(`"version" must look like 1.0.0`); | |
| 108 | const description = text(o.description); | |
| 109 | if (description === undefined) return bad(`"description" is missing`); | |
| 110 | ||
| 111 | if (!Array.isArray(o.apps) || o.apps.length === 0 || o.apps.length > MAX_APPS) | |
| 112 | return bad(`"apps" must list 1 to ${MAX_APPS} apps`); | |
| 113 | const apps: PackApp[] = []; | |
| 114 | for (const a of o.apps as unknown[]) { | |
| 115 | const r = (a ?? {}) as Record<string, unknown>; | |
| 116 | const appName = typeof r.name === "string" && APP_NAME.test(r.name) ? r.name : undefined; | |
| 117 | const source = sourceOf(r.source); | |
| 118 | const about = text(r.description, 300); | |
| 119 | if (appName === undefined || source === undefined || about === undefined) | |
| 120 | return bad(`each app needs a name, a description and an https "source" naming a public repository`); | |
| 121 | if (apps.some((x) => x.name === appName)) return bad(`two apps are named "${appName}"`); | |
| 122 | const branch = r.branch === undefined ? undefined : text(r.branch, 100); | |
| 123 | if (r.branch !== undefined && (branch === undefined || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(branch))) | |
| 124 | return bad(`app "${appName}" has an unusable "branch"`); | |
| 125 | apps.push({ name: appName, description: about, source, ...(branch === undefined ? {} : { branch }) }); | |
| 126 | } | |
| 127 | ||
| 128 | const crew: PackCrew[] = []; | |
| 129 | const asked = o.crew === undefined ? [] : o.crew; | |
| 130 | if (!Array.isArray(asked) || asked.length > MAX_CREW) return bad(`"crew" must list at most ${MAX_CREW} agents`); | |
| 131 | for (const c of asked as unknown[]) { | |
| 132 | const r = (c ?? {}) as Record<string, unknown>; | |
| 133 | const username = typeof r.username === "string" && USERNAME.test(r.username) ? r.username : undefined; | |
| 134 | const brief = text(r.brief, 4_000); | |
| 135 | const roles = Array.isArray(r.roles) && r.roles.length > 0 ? r.roles : undefined; | |
| 136 | if (username === undefined || brief === undefined || roles === undefined) | |
| 137 | return bad(`each crew member needs a username, roles and a brief`); | |
| 138 | if (!roles.every((x): x is CrewRole => CREW_ROLES.includes(x as CrewRole))) | |
| 139 | return bad(`crew roles are ${CREW_ROLES.join(", ")}`); | |
| 140 | if (crew.some((x) => x.username === username)) return bad(`two crew members are named "${username}"`); | |
| 141 | const model = r.model === undefined ? undefined : text(r.model, 80); | |
| 142 | if (r.model !== undefined && model === undefined) return bad(`crew "${username}" has an unusable "model"`); | |
| 143 | const budget = r.budgetUsd; | |
| 144 | if (budget !== undefined && (typeof budget !== "number" || !(budget >= 0 && budget <= 100))) | |
| 145 | return bad(`crew "${username}" has an unusable "budgetUsd"`); | |
| 146 | crew.push({ | |
| 147 | username, | |
| 148 | roles: [...new Set(roles)], | |
| 149 | brief, | |
| 150 | ...(model === undefined ? {} : { model }), | |
| 151 | ...(budget === undefined ? {} : { budgetUsd: budget }), | |
| 152 | }); | |
| 153 | } | |
| 154 | return ok({ format: PACK_FORMAT, name, title, version, description, apps, crew }); | |
| 155 | } |
src/packs/words.ts added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | /** | |
| 2 | * What a pack's page says, in one place for the web page and the terminal | |
| 3 | * screen: the commands that deploy it, and what happens to a stranger's pull. | |
| 4 | */ | |
| 5 | ||
| 6 | import type { Pack } from "./pack.ts"; | |
| 7 | ||
| 8 | /** The commands, one per line. `origin` is the forge's configured public address, never a request's Host. */ | |
| 9 | export function deployCommands(p: Pack, origin: string | undefined): readonly string[] { | |
| 10 | const from = origin === undefined ? "<this forge's address>" : origin.replace(/\/+$/, ""); | |
| 11 | return [ | |
| 12 | `git clone ${from}/trevato/forge.git && cd forge && bun install`, | |
| 13 | `bun run src/main.ts # your forge, on http://localhost:3000`, | |
| 14 | `# make an account there, create an access token, then:`, | |
| 15 | `FORGE_URL=http://localhost:3000 FORGE_TOKEN=forge_… bun scripts/pack.ts deploy ${p.name}`, | |
| 16 | ]; | |
| 17 | } | |
| 18 | ||
| 19 | export const STRANGER_WORDS: readonly string[] = [ | |
| 20 | "You can read and comment with no account. To send a change, get a guest account here, or use a forge of your own: fork an app, change it, and send a pull.", | |
| 21 | "A guest's pull waits for a person to review it. Any path it touches that holds secrets, credentials, permissions or release machinery is named on its page, and it cannot change them by landing.", | |
| 22 | ]; | |
| 23 | ||
| 24 | export const BRINGS_WORDS = | |
| 25 | "Each app comes with its history. No data, secrets or variables come with a pack, and the crew waits for the model credential you give it."; |
src/tui/forge.ts +2
| @@ -14,6 +14,7 @@ import { cleanQuery } from "../core/search.ts"; | ||
| 14 | 14 | import { guestCodeSearch } from "../http/strangers.ts"; |
| 15 | 15 | import { canRead, getRepo, type Repo } from "../core/repos.ts"; |
| 16 | 16 | import { sourceFor } from "../view/source.ts"; |
| 17 | import { packsCommand } from "./screens/packs.ts"; | |
| 17 | 18 | import { App, type Ctx, type Screen } from "./app/app.ts"; |
| 18 | 19 | import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts"; |
| 19 | 20 | import { G } from "./term/screen.ts"; |
| @@ -191,6 +192,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 191 | 192 | hint: "on the web", |
| 192 | 193 | run: () => openOnWeb(session, ctx, "/explore", "The explorer"), |
| 193 | 194 | }, |
| 195 | packsCommand(session, ctx), | |
| 194 | 196 | { |
| 195 | 197 | // A person's or an org's page is the web's (#636): the terminal |
| 196 | 198 | // hands over its address. |
src/tui/screens/packs.ts added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | /** | |
| 2 | * `/packs` in the terminal: the premade forges this forge ships, one pack's | |
| 3 | * contents (apps, crew), the commands that deploy it and what happens to a | |
| 4 | * stranger's pull. Words come from `src/packs/words.ts`, the same the web page | |
| 5 | * prints. Anyone may open it; it reads the catalog, nothing of the forge's. | |
| 6 | */ | |
| 7 | ||
| 8 | import { catalog } from "../../packs/catalog.ts"; | |
| 9 | import type { Pack } from "../../packs/pack.ts"; | |
| 10 | import { BRINGS_WORDS, deployCommands, STRANGER_WORDS } from "../../packs/words.ts"; | |
| 11 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 12 | import { binding } from "../app/keys.ts"; | |
| 13 | import { style } from "../term/style.ts"; | |
| 14 | import { truncate, wrap } from "../term/width.ts"; | |
| 15 | import { Scroll } from "./common.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | ||
| 18 | /** One pack as text lines: what it brings, the commands, and the stranger's way in. */ | |
| 19 | export function packLines(p: Pack, origin: string | undefined, width = 80): string[] { | |
| 20 | const w = Math.max(20, width); | |
| 21 | return [ | |
| 22 | `${p.title} ${p.version}`, | |
| 23 | ...wrap(p.description, w), | |
| 24 | "", | |
| 25 | "Apps", | |
| 26 | ...p.apps.map((a) => ` ${a.name} ${a.source}`), | |
| 27 | ...(p.crew.length === 0 ? [] : ["", "Crew", ...p.crew.map((c) => ` ${c.username} ${c.roles.join(", ")}`)]), | |
| 28 | "", | |
| 29 | ...wrap(BRINGS_WORDS, w), | |
| 30 | "", | |
| 31 | "Deploy it on your own machine", | |
| 32 | ...deployCommands(p, origin).map((l) => ` ${l}`), | |
| 33 | "", | |
| 34 | "Help build these apps", | |
| 35 | ...STRANGER_WORDS.flatMap((t) => [...wrap(t, w), ""]).slice(0, -1), | |
| 36 | ]; | |
| 37 | } | |
| 38 | ||
| 39 | function packScreen(ctx: Ctx, p: Pack, origin: string | undefined): Screen { | |
| 40 | const scroll = new Scroll(); | |
| 41 | let lines: string[] = []; | |
| 42 | return { | |
| 43 | title: `${p.title} pack`, | |
| 44 | keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")], | |
| 45 | render(area, s) { | |
| 46 | const c = ctx.theme.c; | |
| 47 | lines = packLines(p, origin, area.w - 4); | |
| 48 | const from = scroll.window(area.h, lines.length); | |
| 49 | for (let i = 0; i < area.h; i++) { | |
| 50 | const line = lines[from + i]; | |
| 51 | if (line !== undefined) s.text(area.x + 1, area.y + i, line, style(c.fg, c.bg), area.x + area.w); | |
| 52 | } | |
| 53 | }, | |
| 54 | key(e) { | |
| 55 | if (e.key === "esc" || e.key === "q") { | |
| 56 | ctx.pop(); | |
| 57 | return true; | |
| 58 | } | |
| 59 | return scroll.key(e, lines.length); | |
| 60 | }, | |
| 61 | }; | |
| 62 | } | |
| 63 | ||
| 64 | export function packsScreen(ctx: Ctx, origin: string | undefined): Screen { | |
| 65 | const { packs } = catalog(); | |
| 66 | let at = 0; | |
| 67 | return { | |
| 68 | title: "Forge packs", | |
| 69 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["esc"], "close")], | |
| 70 | render(area, s) { | |
| 71 | const c = ctx.theme.c; | |
| 72 | s.text(area.x + 1, area.y, "Forge packs: a whole forge to start from", style(c.mutedFg, c.bg), area.x + area.w); | |
| 73 | packs.forEach((p, i) => { | |
| 74 | const on = i === at; | |
| 75 | const row = `${on ? "> " : " "}${p.title} v${p.version} ${p.apps.length} apps, ${p.crew.length} crew ${p.description}`; | |
| 76 | if (2 + i < area.h) s.text(area.x + 1, area.y + 2 + i, truncate(row, area.w - 2), on ? style(c.accent, c.bg) : style(c.fg, c.bg), area.x + area.w); | |
| 77 | }); | |
| 78 | }, | |
| 79 | key(e) { | |
| 80 | if (e.ctrl || e.alt) return false; | |
| 81 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 82 | else if (e.key === "j" || e.key === "down") at = Math.min(packs.length - 1, at + 1); | |
| 83 | else if (e.key === "k" || e.key === "up") at = Math.max(0, at - 1); | |
| 84 | else if (e.key === "enter") { | |
| 85 | const p = packs[at]; | |
| 86 | if (p !== undefined) ctx.push(packScreen(ctx, p, origin)); | |
| 87 | } else return false; | |
| 88 | return true; | |
| 89 | }, | |
| 90 | }; | |
| 91 | } | |
| 92 | ||
| 93 | export const packsCommand = (session: Session, ctx: Ctx) => ({ | |
| 94 | id: "packs", | |
| 95 | title: "Forge packs", | |
| 96 | hint: "premade forges to deploy, and how to help build their apps", | |
| 97 | run: () => ctx.push(packsScreen(ctx, session.app.config.url)), | |
| 98 | }); |
src/web/layout.ts +4 −2
| @@ -213,7 +213,7 @@ const newIssueItem = (ctx: Ctx): MenuItem[] => | ||
| 213 | 213 | function guestMenu(ctx: Ctx, theme: ThemeName | undefined): Html { |
| 214 | 214 | return menu({ |
| 215 | 215 | trigger: themeGlyph, |
| 216 | items: [...newIssueItem(ctx), helpItem, themeForm(theme)], | |
| 216 | items: [...newIssueItem(ctx), { label: "Packs", href: "/packs", icon: "rocket" }, helpItem, themeForm(theme)], | |
| 217 | 217 | label: "Appearance and help", |
| 218 | 218 | triggerOptions: { extra: "btn-icon" }, |
| 219 | 219 | }); |
| @@ -287,6 +287,7 @@ function userMenu( | ||
| 287 | 287 | { label: "Notes", href: "/notes", icon: "comment" }, |
| 288 | 288 | { label: "Talk", href: "/talk", icon: "comment" }, |
| 289 | 289 | { label: "Explore", href: "/explore", icon: "search" }, |
| 290 | { label: "Packs", href: "/packs", icon: "rocket" }, | |
| 290 | 291 | { label: "Map", href: "/map", icon: "map" }, |
| 291 | 292 | ...featureItems(ctx, user), |
| 292 | 293 | { separator: true, label: "" }, |
| @@ -358,6 +359,7 @@ const STATIC_PALETTE: PaletteItem[] = [ | ||
| 358 | 359 | { t: "Home", h: "/", c: "Go to" }, |
| 359 | 360 | { t: "Apps", h: "/apps", c: "Go to" }, |
| 360 | 361 | { t: "Explore", h: "/explore", c: "Go to" }, |
| 362 | { t: "Forge packs", h: "/packs", c: "Go to" }, | |
| 361 | 363 | { t: "Map", h: "/map", c: "Go to" }, |
| 362 | 364 | { t: "What you asked for", h: "/asked", c: "Go to" }, |
| 363 | 365 | { t: "Front page", h: "/front", c: "Go to" }, |
| @@ -375,7 +377,7 @@ const STATIC_PALETTE: PaletteItem[] = [ | ||
| 375 | 377 | ]; |
| 376 | 378 | |
| 377 | 379 | /** What a signed-out visitor can reach: places to go, not Create and Settings rows that end at a sign-in wall. */ |
| 378 | const VISITOR_PALETTE = new Set(["Home", "Apps", "Explore", "Front page", "Search"]); | |
| 380 | const VISITOR_PALETTE = new Set(["Home", "Apps", "Explore", "Forge packs", "Front page", "Search"]); | |
| 379 | 381 | |
| 380 | 382 | /** First path segments that are the forge's own pages, never an owner: no repository read for them. */ |
| 381 | 383 | const NOT_AN_OWNER = new Set([ |
src/web/pages/packs.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** | |
| 2 | * `/packs` and `/packs/:name`: the forge packs this forge ships (`src/packs/`). | |
| 3 | * A pack's page says what it brings, the one command that deploys it on your | |
| 4 | * own machine, and how a person with no account can help build its apps and | |
| 5 | * what happens to their pull. Server-rendered, no script; anyone may read it. | |
| 6 | */ | |
| 7 | ||
| 8 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 9 | import { catalog, packNamed } from "../../packs/catalog.ts"; | |
| 10 | import type { Ctx } from "../../http/context.ts"; | |
| 11 | import { notFoundPage, offersSignup, pageHead } from "../chrome.ts"; | |
| 12 | import { deployCommands, BRINGS_WORDS, STRANGER_WORDS } from "../../packs/words.ts"; | |
| 13 | import { html } from "../html.ts"; | |
| 14 | import { page } from "../layout.ts"; | |
| 15 | import { btn, card, listCard, row } from "../ui.ts"; | |
| 16 | ||
| 17 | const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? "" : "s"}`; | |
| 18 | ||
| 19 | // GET /packs | |
| 20 | export function packsPage(ctx: Ctx): Response { | |
| 21 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 22 | const started = performance.now(); | |
| 23 | const { packs } = catalog(); | |
| 24 | const body = html`${pageHead("Forge packs", { | |
| 25 | sub: "A whole forge to start from: real apps with their history, and a crew set up to work on them.", | |
| 26 | })} | |
| 27 | ${listCard( | |
| 28 | packs.map((p) => | |
| 29 | row({ | |
| 30 | title: html`<a href="/packs/${p.name}">${p.title}</a>`, | |
| 31 | meta: html`${p.description} ${plural(p.apps.length, "app")}, ${plural(p.crew.length, "crew member")}.`, | |
| 32 | trail: html`v${p.version}`, | |
| 33 | }), | |
| 34 | ), | |
| 35 | )}`; | |
| 36 | const response = page({ ctx, title: "Forge packs", body }); | |
| 37 | notePhase(ctx.request, "render", performance.now() - started); | |
| 38 | return response; | |
| 39 | } | |
| 40 | ||
| 41 | // GET /packs/:name | |
| 42 | export function packPage(ctx: Ctx): Response { | |
| 43 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 44 | const started = performance.now(); | |
| 45 | const pack = packNamed(ctx.params.name ?? ""); | |
| 46 | if (pack === undefined) return notFoundPage(ctx); | |
| 47 | const signup = ctx.auth.user === undefined && offersSignup(ctx); | |
| 48 | const body = html`${pageHead(`${pack.title} pack`, { sub: `${pack.description} Version ${pack.version}.` })} | |
| 49 | ${card( | |
| 50 | html`${listCard( | |
| 51 | pack.apps.map((a) => | |
| 52 | row({ | |
| 53 | title: html`<a href="${a.source}" rel="noopener noreferrer">${a.name}</a>`, | |
| 54 | meta: html`${a.description}`, | |
| 55 | }), | |
| 56 | ), | |
| 57 | )} | |
| 58 | ${pack.crew.length === 0 | |
| 59 | ? null | |
| 60 | : html`<h3>The crew it starts with</h3> | |
| 61 | ${listCard(pack.crew.map((c) => row({ title: c.username, meta: html`${c.roles.join(", ")}. ${c.brief}` })))}`} | |
| 62 | <p class="muted">${BRINGS_WORDS}</p>`, | |
| 63 | { head: html`<h2>What it brings</h2>` }, | |
| 64 | )} | |
| 65 | ${card( | |
| 66 | html`<div class="stack stack-tight"> | |
| 67 | ${deployCommands(pack, ctx.app.config.url).map((l) => html`<code class="mono break">${l}</code>`)} | |
| 68 | </div>`, | |
| 69 | { head: html`<h2>Deploy it on your own machine</h2>` }, | |
| 70 | )} | |
| 71 | ${card( | |
| 72 | html`${STRANGER_WORDS.map((w) => html`<p>${w}</p>`)} | |
| 73 | <p> | |
| 74 | ${signup ? btn("Get an account", { href: "/signup", variant: "primary" }) : null} | |
| 75 | ${btn("Start your own forge", { href: "/forges/new" })} | |
| 76 | </p>`, | |
| 77 | { head: html`<h2>Help build these apps</h2>` }, | |
| 78 | )}`; | |
| 79 | const response = page({ ctx, title: `${pack.title} pack`, body }); | |
| 80 | notePhase(ctx.request, "render", performance.now() - started); | |
| 81 | return response; | |
| 82 | } |
test/forge-looks.test.ts +1
| @@ -26,6 +26,7 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 26 | 26 | "/family": "no picture: the cap of 24 looks went to the preview's /waves and /milestones; the page is covered by its own tests", |
| 27 | 27 | "/llms.txt": "plain markdown for machines, not a page to picture", |
| 28 | 28 | "/agents": "/llms.txt drawn as a page for an assistant; no picture: the cap of 24 looks is spent, and test/agents-page.test.ts covers it", |
| 29 | "/packs": "no picture: the cap of 24 looks is spent; the page is covered by test/packs.test.ts", | |
| 29 | 30 | "/robots.txt": "plain text for crawlers", |
| 30 | 31 | "/openapi.json": "a redirect to the contract document", |
| 31 | 32 | "/favicon.ico": "the tab icon, an image", |
test/nightly-output.test.ts +1 −1
| @@ -89,7 +89,7 @@ describe("a nightly job's output", () => { | ||
| 89 | 89 | |
| 90 | 90 | test("only the hunt script's steps are read for what they filed, never install", () => { |
| 91 | 91 | for (const job of JOBS) |
| 92 | for (const step of job.steps) expect([step.label, readsFiled(step)]).toEqual([step.label, step.label !== "install"]); | |
| 92 | for (const step of job.steps) expect([step.label, readsFiled(step)]).toEqual([step.label, step.command.startsWith("bun scripts/flake-hunt.ts ")]); | |
| 93 | 93 | }); |
| 94 | 94 | |
| 95 | 95 | test("the record says what the run filed, or that it filed nothing", () => { |
test/now-links.test.ts +1
| @@ -111,6 +111,7 @@ const MENU_TITLES: Record<string, string> = { | ||
| 111 | 111 | Notes: "Notes", |
| 112 | 112 | Talk: "Talk", |
| 113 | 113 | Explore: "Explore", |
| 114 | Packs: "Forge packs", | |
| 114 | 115 | Map: "Map", |
| 115 | 116 | "New app": "New app", |
| 116 | 117 | "New repository": "New repository", |
test/packs-hands.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** The real hands of a pack deploy: no credential leaves for a stranger's host, 409 alone means "exists", the terminal screen. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { serve } from "../src/http/server.ts"; | |
| 9 | import { packNamed } from "../src/packs/catalog.ts"; | |
| 10 | import { deployCommands } from "../src/packs/words.ts"; | |
| 11 | import { createForgeApp, forgeCommands } from "../src/tui/forge.ts"; | |
| 12 | import { packLines, packsScreen } from "../src/tui/screens/packs.ts"; | |
| 13 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 14 | import { cloneEnv, pushEnv, realHands } from "../scripts/pack.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | describe("git credentials", () => { | |
| 21 | test("the clone env carries no Authorization and no extra header", () => { | |
| 22 | const env = cloneEnv(); | |
| 23 | expect(Object.entries(env).some(([k, v]) => /authorization|extraheader/i.test(k + v))).toBe(false); | |
| 24 | expect(env.GIT_CONFIG_COUNT).toBeUndefined(); | |
| 25 | }); | |
| 26 | test("the push env scopes the header to the forge's own address", () => { | |
| 27 | const env = pushEnv("https://forge.example/", "tok"); | |
| 28 | expect(env.GIT_CONFIG_KEY_0).toBe("http.https://forge.example/.extraHeader"); | |
| 29 | expect(env.GIT_CONFIG_VALUE_0).toContain("Authorization: Basic "); | |
| 30 | }); | |
| 31 | test("cloning a source sends the source host nothing but git's own asks", async () => { | |
| 32 | const seen: (string | null)[] = []; | |
| 33 | const stranger = Bun.serve({ | |
| 34 | port: 0, | |
| 35 | fetch(req) { | |
| 36 | seen.push(req.headers.get("authorization")); | |
| 37 | return new Response("no", { status: 404 }); | |
| 38 | }, | |
| 39 | }); | |
| 40 | try { | |
| 41 | const r = await realHands("http://127.0.0.1:1", "secret-token").carryHistory(`http://127.0.0.1:${stranger.port}/x.git`, "o", "n", undefined); | |
| 42 | expect(r.ok).toBe(false); | |
| 43 | expect(seen.length).toBeGreaterThan(0); | |
| 44 | expect(seen.every((h) => h === null)).toBe(true); | |
| 45 | } finally { | |
| 46 | await stranger.stop(true); | |
| 47 | } | |
| 48 | }); | |
| 49 | }); | |
| 50 | ||
| 51 | describe("makeAgent", () => { | |
| 52 | const answering = (status: number) => Bun.serve({ port: 0, fetch: () => new Response("{}", { status }) }); | |
| 53 | const member = { username: "helper", roles: ["builder" as const], brief: "Help." }; | |
| 54 | test("only 409 means the crew member exists", async () => { | |
| 55 | for (const [status, exists] of [[409, true], [422, false], [500, false]] as const) { | |
| 56 | const s = answering(status); | |
| 57 | try { | |
| 58 | const r = await realHands(`http://127.0.0.1:${s.port}`, "t").makeAgent(member); | |
| 59 | expect(r.ok).toBe(false); | |
| 60 | if (!r.ok) expect([status, r.exists]).toEqual([status, exists]); | |
| 61 | } finally { | |
| 62 | await s.stop(true); | |
| 63 | } | |
| 64 | } | |
| 65 | }); | |
| 66 | }); | |
| 67 | ||
| 68 | describe("deploy words", () => { | |
| 69 | const games = packNamed("games")!; | |
| 70 | test("the clone source is the configured address, never a request's Host", () => { | |
| 71 | expect(deployCommands(games, "https://f.example/")[0]).toContain("git clone https://f.example/trevato/forge.git"); | |
| 72 | expect(deployCommands(games, undefined)[0]).not.toContain("http"); | |
| 73 | }); | |
| 74 | test("the web page does not echo a forged Host", async () => { | |
| 75 | const dir = await mkdtemp(join(tmpdir(), "packs-host-")); | |
| 76 | const a = createApp({ dataDir: dir, port: 0 }); | |
| 77 | const servers = await serve(a); | |
| 78 | try { | |
| 79 | const res = await fetch(`${servers.url}/packs/games`, { headers: { host: "evil.example" } }); | |
| 80 | expect(res.status).toBe(200); | |
| 81 | expect(await res.text()).not.toContain("git clone http://evil.example"); | |
| 82 | } finally { | |
| 83 | await servers.stop(); | |
| 84 | a.close(); | |
| 85 | await rm(dir, { recursive: true, force: true }); | |
| 86 | } | |
| 87 | }); | |
| 88 | }); | |
| 89 | ||
| 90 | describe("terminal screen", () => { | |
| 91 | let dir: string; | |
| 92 | let app: App; | |
| 93 | beforeAll(async () => { | |
| 94 | dir = await mkdtemp(join(tmpdir(), "packs-tui-")); | |
| 95 | app = createApp({ dataDir: dir, port: 0 }); | |
| 96 | const r = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 97 | if (!r.ok) throw new Error(r.error); | |
| 98 | }); | |
| 99 | afterAll(async () => { | |
| 100 | app.close(); | |
| 101 | await rm(dir, { recursive: true, force: true }); | |
| 102 | }); | |
| 103 | const open = () => createForgeApp(app, { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t" }, { terminal: headlessTerminal(120, 40), exitHooks: false }); | |
| 104 | ||
| 105 | test("a stranger is offered it in the palette", () => { | |
| 106 | const { app: tui, session } = open(); | |
| 107 | expect(forgeCommands(session)(tui.ctx).map((c) => c.id)).toContain("packs"); | |
| 108 | tui.stop?.(); | |
| 109 | }); | |
| 110 | test("it lists the packs, opens one, and says the deploy command and what happens to a pull", async () => { | |
| 111 | const { app: tui } = open(); | |
| 112 | tui.start(); | |
| 113 | tui.ctx.push(packsScreen(tui.ctx, undefined)); | |
| 114 | await settleApp(tui); | |
| 115 | const list = tui.screen.toText(); | |
| 116 | expect(list).toContain("Games"); | |
| 117 | expect(list).toContain("Business"); | |
| 118 | tui.ctx.pop(); | |
| 119 | const lines = packLines(packNamed("games")!, undefined).join("\n"); | |
| 120 | expect(lines).toContain("scripts/pack.ts deploy games"); | |
| 121 | expect(lines).toContain("waits for a person"); | |
| 122 | expect(lines).toContain("2048"); | |
| 123 | tui.stop?.(); | |
| 124 | }); | |
| 125 | }); |
test/packs.test.ts added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | /** Packs: the definition is checked, the shipped two are sound, the deploy keeps its order, and the pages answer. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { catalog } from "../src/packs/catalog.ts"; | |
| 9 | import { deployPack, type Hands } from "../src/packs/deploy.ts"; | |
| 10 | import { parsePack, sourceOf } from "../src/packs/pack.ts"; | |
| 11 | ||
| 12 | const good = { | |
| 13 | pack: 1, | |
| 14 | name: "mini", | |
| 15 | title: "Mini", | |
| 16 | version: "1.0.0", | |
| 17 | description: "One app.", | |
| 18 | apps: [{ name: "one", description: "An app.", source: "https://github.com/a/b" }], | |
| 19 | crew: [{ username: "helper", roles: ["builder"], brief: "Help." }], | |
| 20 | }; | |
| 21 | const parse = (o: unknown) => parsePack(JSON.stringify(o)); | |
| 22 | ||
| 23 | describe("pack definition", () => { | |
| 24 | test("a good pack parses", () => { | |
| 25 | const r = parse(good); | |
| 26 | expect(r.ok).toBe(true); | |
| 27 | }); | |
| 28 | test("a pack brings no data, secrets or variables, at any depth", () => { | |
| 29 | for (const extra of [{ secrets: {} }, { env: {} }, { apps: [{ ...good.apps[0], variables: [] }] }, { crew: [{ ...good.crew[0], token: "x" }] }]) { | |
| 30 | const r = parse({ ...good, ...extra }); | |
| 31 | expect(r.ok).toBe(false); | |
| 32 | if (!r.ok) expect(r.error).toContain("not allowed"); | |
| 33 | } | |
| 34 | }); | |
| 35 | test("sources are public https repositories without credentials", () => { | |
| 36 | expect(sourceOf("https://github.com/a/b.git")).toBe("https://github.com/a/b"); | |
| 37 | for (const bad of ["http://github.com/a/b", "https://u:[email protected]/a/b", "[email protected]:a/b", "https://github.com/a", "https://github.com/a/b?x=1", "file:///etc"]) | |
| 38 | expect(sourceOf(bad)).toBeUndefined(); | |
| 39 | }); | |
| 40 | test("each field is checked", () => { | |
| 41 | expect(parse({ ...good, pack: 2 }).ok).toBe(false); | |
| 42 | expect(parse({ ...good, name: "Bad Name" }).ok).toBe(false); | |
| 43 | expect(parse({ ...good, version: "1" }).ok).toBe(false); | |
| 44 | expect(parse({ ...good, apps: [] }).ok).toBe(false); | |
| 45 | expect(parse({ ...good, crew: [{ ...good.crew[0], roles: ["admin"] }] }).ok).toBe(false); | |
| 46 | expect(parsePack("nope").ok).toBe(false); | |
| 47 | }); | |
| 48 | }); | |
| 49 | ||
| 50 | describe("shipped packs", () => { | |
| 51 | test("Games and Business ship and parse", () => { | |
| 52 | const c = catalog(); | |
| 53 | expect(c.problems).toEqual([]); | |
| 54 | expect(c.packs.map((p) => p.name)).toEqual(["business", "games"]); | |
| 55 | for (const p of c.packs) expect(p.apps.length).toBeGreaterThan(0); | |
| 56 | }); | |
| 57 | }); | |
| 58 | ||
| 59 | describe("deploy", () => { | |
| 60 | test("makes apps, carries history, adds crew, keeps what exists and reports failures", async () => { | |
| 61 | const calls: string[] = []; | |
| 62 | const hands: Hands = { | |
| 63 | async makeApp(name) { | |
| 64 | calls.push(`app ${name}`); | |
| 65 | return name === "one" ? { ok: true, owner: "me" } : { ok: false, exists: true, why: "x" }; | |
| 66 | }, | |
| 67 | async carryHistory(source, owner, name) { | |
| 68 | calls.push(`history ${source} -> ${owner}/${name}`); | |
| 69 | return { ok: true }; | |
| 70 | }, | |
| 71 | async makeAgent(a) { | |
| 72 | calls.push(`agent ${a.username}`); | |
| 73 | return { ok: false, exists: false, why: "no" }; | |
| 74 | }, | |
| 75 | }; | |
| 76 | const pack = parse({ ...good, apps: [...good.apps, { name: "two", description: "d", source: "https://github.com/c/d" }] }); | |
| 77 | if (!pack.ok) throw new Error(pack.error); | |
| 78 | const steps = await deployPack(pack.value, hands); | |
| 79 | expect(calls).toEqual(["app one", "history https://github.com/a/b -> me/one", "app two", "agent helper"]); | |
| 80 | expect(steps.map((s) => s.state)).toEqual(["done", "kept", "failed"]); | |
| 81 | }); | |
| 82 | }); | |
| 83 | ||
| 84 | describe("pages", () => { | |
| 85 | let dataDir: string; | |
| 86 | let app: App; | |
| 87 | let servers: Servers; | |
| 88 | beforeAll(async () => { | |
| 89 | dataDir = await mkdtemp(join(tmpdir(), "forge-packs-")); | |
| 90 | app = createApp({ dataDir, port: 0 }); | |
| 91 | servers = await serve(app); | |
| 92 | }); | |
| 93 | afterAll(async () => { | |
| 94 | await servers.stop(); | |
| 95 | app.close(); | |
| 96 | await rm(dataDir, { recursive: true, force: true }); | |
| 97 | }); | |
| 98 | test("a stranger reads the list and a pack's page, with the deploy command and what happens to a pull", async () => { | |
| 99 | const list = await fetch(`${servers.url}/packs`); | |
| 100 | expect(list.status).toBe(200); | |
| 101 | const text = await list.text(); | |
| 102 | expect(text).toContain("/packs/games"); | |
| 103 | expect(text).toContain("/packs/business"); | |
| 104 | const one = await fetch(`${servers.url}/packs/games`); | |
| 105 | expect(one.status).toBe(200); | |
| 106 | const body = await one.text(); | |
| 107 | expect(body).toContain("scripts/pack.ts deploy games"); | |
| 108 | expect(body).toContain("waits for a person"); | |
| 109 | expect(body).toContain("gabrielecirulli/2048"); | |
| 110 | expect((await fetch(`${servers.url}/packs/nothing`)).status).toBe(404); | |
| 111 | }); | |
| 112 | }); |
test/palette-repos.test.ts +1 −1
| @@ -114,7 +114,7 @@ test("a visitor's palette holds places to go and Sign in; a signed-in one holds | ||
| 114 | 114 | return (JSON.parse(m[1]!) as { items: { t: string; h: string; c?: string }[] }).items.filter((i) => i.c !== "Repositories"); |
| 115 | 115 | }; |
| 116 | 116 | const out = await rows({}, "/explore?q=a%26b"); |
| 117 | expect(out.map((i) => i.t)).toEqual(["Home", "Apps", "Explore", "Front page", "Search", "Sign in"]); | |
| 117 | expect(out.map((i) => i.t)).toEqual(["Home", "Apps", "Explore", "Forge packs", "Front page", "Search", "Sign in"]); | |
| 118 | 118 | expect(out.find((i) => i.t === "Sign in")!.h).toBe("/signin"); |
| 119 | 119 | const inn = (await rows({ cookie }, "/explore")).map((i) => i.t); |
| 120 | 120 | for (const t of ["New app", "New repository", "Migrate a repository", "New organization", "What you asked for", "Account settings", "Access tokens", "SSH keys", "Origins", "Map"]) expect(inn).toContain(t); |
test/palette-roster.test.ts +4
| @@ -47,6 +47,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 47 | 47 | expect(roster(admin)).toEqual([ |
| 48 | 48 | ["home", "Home", "your repositories"], |
| 49 | 49 | ["explore", "Explore", "on the web"], |
| 50 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], | |
| 50 | 51 | ["profile", "Open a profile", "username or org"], |
| 51 | 52 | ["repos", "Repositories", "the repositories you may write"], |
| 52 | 53 | ["new-issue-anywhere", "New issue", "pick a repository"], |
| @@ -148,6 +149,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 148 | 149 | expect(roster({ ...admin, kind: "agent" })).toEqual([ |
| 149 | 150 | ["home", "Home", "your repositories"], |
| 150 | 151 | ["explore", "Explore", "on the web"], |
| 152 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], | |
| 151 | 153 | ["profile", "Open a profile", "username or org"], |
| 152 | 154 | ["repos", "Repositories", "the repositories you may write"], |
| 153 | 155 | ["new-issue-anywhere", "New issue", "pick a repository"], |
| @@ -213,6 +215,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 213 | 215 | expect(roster(member)).toEqual([ |
| 214 | 216 | ["home", "Home", "your repositories"], |
| 215 | 217 | ["explore", "Explore", "on the web"], |
| 218 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], | |
| 216 | 219 | ["profile", "Open a profile", "username or org"], |
| 217 | 220 | ["repos", "Repositories", "the repositories you may write"], |
| 218 | 221 | ["new-issue-anywhere", "New issue", "pick a repository"], |
| @@ -275,6 +278,7 @@ test("signed out, the palette offers sign-in and the public entries", () => { | ||
| 275 | 278 | expect(roster()).toEqual([ |
| 276 | 279 | ["home", "Home", "your repositories"], |
| 277 | 280 | ["explore", "Explore", "on the web"], |
| 281 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], | |
| 278 | 282 | ["profile", "Open a profile", "username or org"], |
| 279 | 283 | ["repo", "Open a repository", "owner/name"], |
| 280 | 284 | ["issues", "Issues in a repository", "owner/name"], |
new above
offline
HIGH: this Authorization header applies to every host git talks to, including the pack's source on clone. Scope it to the forge URL (
http.<url>/.extraHeader) and give the clone no credential.