Public: anyone can read this; the crew can change it.
Packs is an empty page: show real packs you can start a forge from, or take it out of the nav #11012
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11006-packs-is-an-empty-page-show-real-packs-y → main 5 commits fork 1 thread · 1 unresolved
20 files
changed +978
−36
docs/features/packs.md +10
| @@ -21,3 +21,13 @@ Not done yet: a hosted "deploy this pack" press, a stable follow link from each | ||
| 21 | 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. |
| 22 | 22 | |
| 23 | 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. |
| 24 | ||
| 25 | ## Curated packs (#11006) | |
| 26 | ||
| 27 | `/packs` shows **curated packs**: a pack is a named, ordered list of public repos on this forge, with a one-line pitch and a crew brief. They live in the forge's database (`curated_pack*`, `src/packs/curated.ts`), not in code or on GitHub, and a site admin who is a person edits them on the pack's page: rename, change the pitch and brief, add a repo (type to search the repos you can see), remove one, move one up or down; every change is saved at once and kept as who, what, when under History. **New pack** on `/packs` makes one. The same rule is the API: `GET /api/v1/packs`, `GET/PUT /api/v1/packs/{name}` with `{title, pitch, brief, repos:["owner/name"…]}` (a new name makes the pack). Anyone reads `shown`, the public repos on this forge; only a person admin also gets `repos` (the list as kept) and `left_out` (each left one with why), because those name private and missing repos. The same holds on the page: "Left out for now" is an admin's. | |
| 28 | ||
| 29 | **The terminal has it too** (`src/tui/screens/packs.ts`, palette "Forge packs"): the list opens curated packs first, then the deployable ones; a pack's screen shows its shown repos and brief, **s** opens the new-forge form with the pack's repos and brief as they stand, and for a person admin **a** adds a repo, **x** removes, **u**/**d** move, **t**/**p**/**b** edit title, pitch and brief, and **n** on the list makes a pack. It goes through `changeRepos`/`editPack` as the page does, reads the database on every draw, so a change in either door is on the other at once. A guest's screen names no private or missing repo and the edit keys do nothing. Test: `test/packs-tui.test.ts`. | |
| 30 | ||
| 31 | Each card shows the repos inside with a live link when the repo runs an app here, how many there are and how fresh, and **Start a forge from this** (`/forges/new?pack=name`): the form opens with the pack as it stands now (its repos ticked, the brief as the direction), so what you post is what is copied, and a later edit never reaches a forge that exists. A repo that is missing or not public is left out and named on the pack's page. Two packs, **Business** and **Gamer**, are seeded with the owner's lists; trim them on their pages. | |
| 32 | ||
| 33 | The seeded repo lists are the owner's and are not checked against a forge here: a name that is missing or not public is left out of the cards and named to an admin, who trims it on the pack's page. Not done: drag to reorder (the arrows do it), a repo's "Add to pack…" menu, an MCP tool (the API is the door), a preview image on each card, and the crew brief is carried as the child's direction, not as a per-agent brief. The older `pack.json` packs still list under "Deploy on your own machine". |
src/contract/openapi/routes/getApiV1Packs.ts added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { method: "get", path: "/api/v1/packs", summary: "The curated packs: { items: [{ name, title, pitch, brief, repos, shown }] }; shown is the public repos on this forge; repos (the list as kept) and left_out come only to a person admin", auth: false, role: "anonymous" }; |
src/contract/openapi/routes/getApiV1PacksName.ts added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { method: "get", path: "/api/v1/packs/{name}", summary: "One curated pack: { name, title, pitch, brief, shown } (shown: the public repos on this forge), and for a person admin also repos, left_out and the history of who changed what and when", auth: false, role: "anonymous", refusals: { not_found: "404: no such pack" } }; |
src/contract/openapi/routes/index.ts +3
| @@ -104,6 +104,8 @@ export { route as getApiV1OpenapiJson } from "./getApiV1OpenapiJson.ts"; | ||
| 104 | 104 | export { route as getApiV1OrgsOrg } from "./getApiV1OrgsOrg.ts"; |
| 105 | 105 | export { route as getApiV1OrgsOrgTeams } from "./getApiV1OrgsOrgTeams.ts"; |
| 106 | 106 | export { route as getApiV1OrgsOrgTeamsTeamMembers } from "./getApiV1OrgsOrgTeamsTeamMembers.ts"; |
| 107 | export { route as getApiV1Packs } from "./getApiV1Packs.ts"; | |
| 108 | export { route as getApiV1PacksName } from "./getApiV1PacksName.ts"; | |
| 107 | 109 | export { route as getApiV1PassportHealth } from "./getApiV1PassportHealth.ts"; |
| 108 | 110 | export { route as getApiV1Releases } from "./getApiV1Releases.ts"; |
| 109 | 111 | export { route as getApiV1Repos } from "./getApiV1Repos.ts"; |
| @@ -374,6 +376,7 @@ export { route as putApiV1FeaturesSlug } from "./putApiV1FeaturesSlug.ts"; | ||
| 374 | 376 | export { route as putApiV1ForgeEconomics } from "./putApiV1ForgeEconomics.ts"; |
| 375 | 377 | export { route as putApiV1ForgesNameCredit } from "./putApiV1ForgesNameCredit.ts"; |
| 376 | 378 | export { route as putApiV1Front } from "./putApiV1Front.ts"; |
| 379 | export { route as putApiV1PacksName } from "./putApiV1PacksName.ts"; | |
| 377 | 380 | export { route as putApiV1ReposOwnerNameAppEnvVar } from "./putApiV1ReposOwnerNameAppEnvVar.ts"; |
| 378 | 381 | export { route as putApiV1ReposOwnerNameAppMembers } from "./putApiV1ReposOwnerNameAppMembers.ts"; |
| 379 | 382 | export { route as putApiV1ReposOwnerNameCrewUsername } from "./putApiV1ReposOwnerNameCrewUsername.ts"; |
src/contract/openapi/routes/putApiV1PacksName.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "put", | |
| 5 | path: "/api/v1/packs/{name}", | |
| 6 | summary: "Curate a pack (a person admin's token, rw scope): the fields sent replace the pack's, a new {name} makes it (201); repos is the ordered list of owner/name, at most 20; every change is kept with who and when; answers the pack", | |
| 7 | auth: true, | |
| 8 | role: "admin", | |
| 9 | body: { title: "string, at most 80", pitch: "string, at most 300", brief: "string, at most 4000", repos: "list of owner/name" }, | |
| 10 | refusals: { | |
| 11 | forbidden: "the caller is not a site admin who is a person (403)", | |
| 12 | invalid: "a field is the wrong shape, or a repo is not owner/name (422)", | |
| 13 | not_found: "no such pack (404)", | |
| 14 | }, | |
| 15 | }; |
src/core/migration-pins/20261110190000-curated-packs.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 1f6c282db35364773b0e12624c96b4b8b7580ce869c8a0d9c840dad59d0d2a1a |
src/core/migrations/20261110190000-curated-packs.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | // Curated packs (#11006): a pack is a named, ordered list of repos on this | |
| 2 | // forge that an admin edits in the forge itself, with a pitch and a crew | |
| 3 | // brief and a kept history. Additive. The first two, Business and Gamer, are | |
| 4 | // seeded with the owner's lists; repos that are missing or not public are | |
| 5 | // left out when the page is drawn, and named there. | |
| 6 | export default ` | |
| 7 | CREATE TABLE curated_pack ( | |
| 8 | name TEXT PRIMARY KEY, | |
| 9 | title TEXT NOT NULL, | |
| 10 | pitch TEXT NOT NULL DEFAULT '', | |
| 11 | brief TEXT NOT NULL DEFAULT '', | |
| 12 | position INTEGER NOT NULL DEFAULT 0, | |
| 13 | created_at TEXT NOT NULL, | |
| 14 | updated_at TEXT NOT NULL | |
| 15 | ); | |
| 16 | CREATE TABLE curated_pack_repo ( | |
| 17 | pack TEXT NOT NULL REFERENCES curated_pack(name) ON DELETE CASCADE ON UPDATE CASCADE, | |
| 18 | repo TEXT NOT NULL, | |
| 19 | position INTEGER NOT NULL, | |
| 20 | PRIMARY KEY (pack, repo) | |
| 21 | ); | |
| 22 | CREATE TABLE curated_pack_change ( | |
| 23 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 24 | pack TEXT NOT NULL, | |
| 25 | actor TEXT NOT NULL, | |
| 26 | what TEXT NOT NULL, | |
| 27 | at TEXT NOT NULL | |
| 28 | ); | |
| 29 | CREATE INDEX curated_pack_change_pack ON curated_pack_change (pack, id); | |
| 30 | INSERT INTO curated_pack (name, title, pitch, brief, position, created_at, updated_at) VALUES | |
| 31 | ('business', 'Business', 'Leads, listings, forms and money: the tools a small business runs on.', | |
| 32 | 'You work for a small business. Put leads, forms and money first: answer a new lead fast, keep every form working, and keep the deal and payment numbers right. Say plainly what you changed and what a customer would notice.', | |
| 33 | 0, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 34 | ('gamer', 'Gamer', 'Games to play and make yours: a shelf, a sandbox, classics and a shooter engine.', | |
| 35 | 'You make games feel good to play. Put play feel and animation first: input that answers on the next frame, motion with weight, sound and feedback on every hit. Say plainly what you changed and how it plays now.', | |
| 36 | 1, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now')); | |
| 37 | INSERT INTO curated_pack_repo (pack, repo, position) VALUES | |
| 38 | ('business','trevato/realty-desk',0),('business','trevato/deal-clock',1),('business','trevato/listing-launch',2), | |
| 39 | ('business','trevato/speed-to-lead',3),('business','trevato/form-engines',4),('business','trevato/business-spawner',5), | |
| 40 | ('business','trevato/bazaar',6), | |
| 41 | ('gamer','trevato/games-shelf',0),('gamer','trevato/sandbox-game',1),('gamer','trevato/pixel-plays',2), | |
| 42 | ('gamer','trevato/scrims',3),('gamer','trevato/chess',4),('gamer','trevato/pong',5),('gamer','trevato/voxel',6), | |
| 43 | ('gamer','trevato/gamers',7),('gamer','trevato/cs-engine',8),('gamer','trevato/survivor-guide',9); | |
| 44 | `; |
src/http/api.ts +2
| @@ -347,6 +347,7 @@ import { passportRoutes } from "./passport_edge.ts"; | ||
| 347 | 347 | import { egressGrantRoutes } from "./api/egress_grants.ts"; |
| 348 | 348 | import { printRoutes } from "./api/print.ts"; |
| 349 | 349 | import { forgeRoutes } from "./api/forges.ts"; |
| 350 | import { packRoutes } from "./api/packs.ts"; | |
| 350 | 351 | import { forgeViewRoutes } from "./api/forge_view.ts"; |
| 351 | 352 | import { economicsRoutes } from "./api/economics.ts"; |
| 352 | 353 | import { releasesRoutes } from "./api/releases.ts"; |
| @@ -3573,6 +3574,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3573 | 3574 | }); |
| 3574 | 3575 | |
| 3575 | 3576 | forgeRoutes(router); |
| 3577 | packRoutes(router); | |
| 3576 | 3578 | forgeViewRoutes(router); |
| 3577 | 3579 | economicsRoutes(router); |
| 3578 | 3580 | releasesRoutes(router); |
src/http/api/packs.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** | |
| 2 | * Curated packs over the API (#11006): `GET /api/v1/packs`, | |
| 3 | * `GET /api/v1/packs/{name}` and `PUT /api/v1/packs/{name}` with | |
| 4 | * `{title, pitch, brief, repos:[owner/name…]}`. The rules live in | |
| 5 | * `packs/curated.ts`, the same ones the page uses. A PUT makes the pack when | |
| 6 | * the name is new; an admin who is not an agent, and a token that may write. | |
| 7 | */ | |
| 8 | ||
| 9 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 10 | import { editPack, getPack, history, listPacks, mayCurate, resolveRepos, type CuratedPack } from "../../packs/curated.ts"; | |
| 11 | import { crossOrigin, type Ctx, type Handler } from "../context.ts"; | |
| 12 | import { apiError, body, forbidden, json, requireScope } from "../respond.ts"; | |
| 13 | import type { Router } from "../router.ts"; | |
| 14 | ||
| 15 | const view = (ctx: Ctx, p: CuratedPack) => ({ | |
| 16 | name: p.name, | |
| 17 | title: p.title, | |
| 18 | pitch: p.pitch, | |
| 19 | brief: p.brief, | |
| 20 | shown: resolveRepos(ctx.app.db, p, ctx.auth.user).filter((r) => r.left === undefined).map((r) => r.full), | |
| 21 | // The list as kept, with why each left one is left out, names private and missing repos: a person admin's only. | |
| 22 | ...(mayCurate(ctx.auth.user) | |
| 23 | ? { repos: p.repos, left_out: resolveRepos(ctx.app.db, p, ctx.auth.user).filter((r) => r.left !== undefined).map((r) => ({ repo: r.full, why: r.left })) } | |
| 24 | : {}), | |
| 25 | }); | |
| 26 | ||
| 27 | export function packRoutes(router: Router<Handler>): void { | |
| 28 | router.add("GET", "/api/v1/packs", (ctx) => { | |
| 29 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 30 | return json({ items: listPacks(ctx.app.db).map((p) => view(ctx, p)), next_cursor: null }); | |
| 31 | }); | |
| 32 | router.add("GET", "/api/v1/packs/:name", (ctx) => { | |
| 33 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 34 | const pack = getPack(ctx.app.db, ctx.params.name ?? ""); | |
| 35 | if (pack === undefined) return apiError("not_found", 404, "no such pack"); | |
| 36 | return json({ ...view(ctx, pack), ...(mayCurate(ctx.auth.user) ? { history: history(ctx.app.db, pack.name) } : {}) }); | |
| 37 | }); | |
| 38 | router.add("PUT", "/api/v1/packs/:name", async (ctx) => { | |
| 39 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 40 | const denied = requireScope(ctx, "rw"); | |
| 41 | if (denied) return denied; | |
| 42 | if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return forbidden(); | |
| 43 | if (!mayCurate(ctx.auth.user)) return forbidden(); | |
| 44 | const input = await body(ctx.request); | |
| 45 | const name = ctx.params.name ?? ""; | |
| 46 | const create = getPack(ctx.app.db, name) === undefined; | |
| 47 | const done = editPack(ctx.app.db, ctx.auth.user, name, { title: input["title"], pitch: input["pitch"], brief: input["brief"], repos: input["repos"] }, create); | |
| 48 | if (!done.ok) return apiError(done.error, done.error === "not_found" ? 404 : done.error === "forbidden" ? 403 : done.error === "exists" ? 409 : 422, done.says); | |
| 49 | return json(view(ctx, done.pack), create ? 201 : 200); | |
| 50 | }); | |
| 51 | } |
src/http/server.ts +4 −1
| @@ -291,7 +291,7 @@ import { HOME_REGIONS } from "../web/studio/home_last.ts"; | ||
| 291 | 291 | import { serviceWorker } from "../web/sw.ts"; |
| 292 | 292 | import { explorePage } from "../web/studio/explore.ts"; |
| 293 | 293 | import { isPacksMirror, MIRROR_PUSH_REFUSED } from "../packs/owner.ts"; |
| 294 | import { packPage, packsMirror, packsPage } from "../web/pages/packs.ts"; | |
| 294 | import { packEdit, packNew, packPage, packRepos, packsMirror, packsPage } from "../web/pages/packs.ts"; | |
| 295 | 295 | import { |
| 296 | 296 | makeItYoursPage, |
| 297 | 297 | makeItYoursReceipt, |
| @@ -846,6 +846,9 @@ export function buildRouter(): Router<Handler> { | ||
| 846 | 846 | // Forge packs: premade forges to deploy, and the way to help build their apps. |
| 847 | 847 | router.add("GET", "/packs", packsPage); |
| 848 | 848 | router.add("POST", "/packs/mirror", packsMirror); |
| 849 | router.add("POST", "/packs/new", packNew); | |
| 850 | router.add("POST", "/packs/:name/edit", packEdit); | |
| 851 | router.add("POST", "/packs/:name/repos", packRepos); | |
| 849 | 852 | router.add("GET", "/packs/:name", packPage); |
| 850 | 853 | router.add("POST", "/front", frontUpdate); |
| 851 | 854 | router.add("POST", "/home/archive", homeArchive); |
src/packs/curated.ts added +202
| @@ -0,0 +1,202 @@ | ||
| 1 | /** | |
| 2 | * Curated packs (#11006): a pack is a named, ordered list of repos on this | |
| 3 | * forge, with a pitch and a crew brief, that an admin edits in the forge | |
| 4 | * itself (tables `curated_pack*`). Plain data in, plain data out: every write | |
| 5 | * goes through `editPack`, which checks, saves and keeps who/what/when, so the | |
| 6 | * page, the API and any agent share one rule. Starting a forge reads the list | |
| 7 | * as it stands at that moment; a forge already started keeps its own copy. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Database } from "bun:sqlite"; | |
| 11 | import { canRead, getRepo, repoDatesFor } from "../core/repos.ts"; | |
| 12 | import type { User } from "../core/users.ts"; | |
| 13 | import { isSafeName } from "../git/guard.ts"; | |
| 14 | ||
| 15 | export const MAX_PACKS = 50; | |
| 16 | export const MAX_PACK_REPOS = 20; | |
| 17 | const NAME = /^[a-z][a-z0-9-]{0,39}$/; | |
| 18 | ||
| 19 | export interface CuratedPack { | |
| 20 | readonly name: string; | |
| 21 | readonly title: string; | |
| 22 | readonly pitch: string; | |
| 23 | readonly brief: string; | |
| 24 | readonly repos: readonly string[]; | |
| 25 | } | |
| 26 | ||
| 27 | export interface Change { | |
| 28 | readonly actor: string; | |
| 29 | readonly what: string; | |
| 30 | readonly at: string; | |
| 31 | } | |
| 32 | ||
| 33 | type Admin = Pick<User, "id" | "username" | "isAdmin" | "kind">; | |
| 34 | export const mayCurate = (u: Admin | undefined): u is Admin => u !== undefined && u.isAdmin && u.kind !== "agent"; | |
| 35 | ||
| 36 | export const slugOf = (title: string): string => | |
| 37 | title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^[^a-z]+/, "").slice(0, 40).replace(/-+$/, ""); | |
| 38 | ||
| 39 | export function listPacks(db: Database): CuratedPack[] { | |
| 40 | const reposOf = new Map<string, string[]>(); | |
| 41 | for (const r of db.query<{ pack: string; repo: string }, []>("SELECT pack, repo FROM curated_pack_repo ORDER BY pack, position").all()) | |
| 42 | reposOf.set(r.pack, [...(reposOf.get(r.pack) ?? []), r.repo]); | |
| 43 | return db | |
| 44 | .query<{ name: string; title: string; pitch: string; brief: string }, []>( | |
| 45 | "SELECT name, title, pitch, brief FROM curated_pack ORDER BY position, name", | |
| 46 | ) | |
| 47 | .all() | |
| 48 | .map((p) => ({ ...p, repos: reposOf.get(p.name) ?? [] })); | |
| 49 | } | |
| 50 | ||
| 51 | /** One pack by name: two reads keyed on the name, not every pack. */ | |
| 52 | export function getPack(db: Database, name: string): CuratedPack | undefined { | |
| 53 | const row = db | |
| 54 | .query<{ name: string; title: string; pitch: string; brief: string }, [string]>("SELECT name, title, pitch, brief FROM curated_pack WHERE name = ?1") | |
| 55 | .get(name); | |
| 56 | if (row === null) return undefined; | |
| 57 | const repos = db.query<{ repo: string }, [string]>("SELECT repo FROM curated_pack_repo WHERE pack = ?1 ORDER BY position").all(name).map((r) => r.repo); | |
| 58 | return { ...row, repos }; | |
| 59 | } | |
| 60 | ||
| 61 | export function history(db: Database, name: string, limit = 20): Change[] { | |
| 62 | return db | |
| 63 | .query<Change, [string, number]>("SELECT actor, what, at FROM curated_pack_change WHERE pack = ?1 ORDER BY id DESC LIMIT ?2") | |
| 64 | .all(name, limit); | |
| 65 | } | |
| 66 | ||
| 67 | /** A repo word as `owner/name`, lowercase, or undefined. */ | |
| 68 | export function repoWord(v: unknown): string | undefined { | |
| 69 | if (typeof v !== "string") return undefined; | |
| 70 | const [owner = "", name = "", ...rest] = v.trim().split("/"); | |
| 71 | return rest.length > 0 || !isSafeName(owner) || !isSafeName(name) ? undefined : `${owner}/${name}`.toLowerCase(); | |
| 72 | } | |
| 73 | ||
| 74 | export type Edit = { title?: unknown; pitch?: unknown; brief?: unknown; repos?: unknown }; | |
| 75 | export type EditResult = { ok: true; pack: CuratedPack } | { ok: false; error: "forbidden" | "not_found" | "invalid" | "exists"; says: string }; | |
| 76 | ||
| 77 | const bad = (says: string): EditResult => ({ ok: false, error: "invalid", says }); | |
| 78 | const clean = (v: unknown, max: number): string | undefined => | |
| 79 | typeof v === "string" && v.length <= max && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v) ? v.trim() : undefined; | |
| 80 | ||
| 81 | /** What changed between two packs, in one sentence per change. */ | |
| 82 | function describe(from: CuratedPack | undefined, to: CuratedPack): string[] { | |
| 83 | if (from === undefined) return [`made the pack with ${to.repos.length} repos`]; | |
| 84 | const out: string[] = []; | |
| 85 | if (from.title !== to.title) out.push(`renamed it to "${to.title}"`); | |
| 86 | if (from.pitch !== to.pitch) out.push("changed the pitch"); | |
| 87 | if (from.brief !== to.brief) out.push("changed the crew brief"); | |
| 88 | for (const r of to.repos) if (!from.repos.includes(r)) out.push(`added ${r}`); | |
| 89 | for (const r of from.repos) if (!to.repos.includes(r)) out.push(`removed ${r}`); | |
| 90 | const kept = to.repos.filter((r) => from.repos.includes(r)); | |
| 91 | if (kept.join() !== from.repos.filter((r) => to.repos.includes(r)).join()) out.push("reordered the repos"); | |
| 92 | return out; | |
| 93 | } | |
| 94 | ||
| 95 | /** | |
| 96 | * The one door. Creates the pack when `name` is new (`create`), else changes | |
| 97 | * the fields given; `repos` replaces the list. An admin who is not an agent. | |
| 98 | */ | |
| 99 | export function editPack(db: Database, user: Admin | undefined, name: string, edit: Edit, create = false): EditResult { | |
| 100 | if (!mayCurate(user)) return { ok: false, error: "forbidden", says: "only a site admin edits packs" }; | |
| 101 | if (!NAME.test(name)) return bad("a pack name is lowercase letters, digits and dashes"); | |
| 102 | const before = getPack(db, name); | |
| 103 | if (create && before !== undefined) return { ok: false, error: "exists", says: `there is already a pack named ${name}` }; | |
| 104 | if (!create && before === undefined) return { ok: false, error: "not_found", says: "no such pack" }; | |
| 105 | const title = edit.title === undefined ? (before?.title ?? name) : clean(edit.title, 80); | |
| 106 | const pitch = edit.pitch === undefined ? (before?.pitch ?? "") : clean(edit.pitch, 300); | |
| 107 | const brief = edit.brief === undefined ? (before?.brief ?? "") : clean(edit.brief, 4000); | |
| 108 | if (title === undefined || title === "") return bad("a title is 1 to 80 characters"); | |
| 109 | if (pitch === undefined) return bad("a pitch is at most 300 characters"); | |
| 110 | if (brief === undefined) return bad("a crew brief is at most 4000 characters"); | |
| 111 | let repos: string[] = [...(before?.repos ?? [])]; | |
| 112 | if (edit.repos !== undefined) { | |
| 113 | if (!Array.isArray(edit.repos) || edit.repos.length > MAX_PACK_REPOS) return bad(`repos is a list of at most ${MAX_PACK_REPOS} owner/name words`); | |
| 114 | repos = []; | |
| 115 | for (const w of edit.repos) { | |
| 116 | const r = repoWord(w); | |
| 117 | if (r === undefined) return bad("each repo is written owner/name"); | |
| 118 | if (!repos.includes(r)) repos.push(r); | |
| 119 | } | |
| 120 | } | |
| 121 | if (before === undefined && (db.query<{ n: number }, []>("SELECT count(*) AS n FROM curated_pack").get()?.n ?? 0) >= MAX_PACKS) | |
| 122 | return bad(`at most ${MAX_PACKS} packs`); | |
| 123 | const after: CuratedPack = { name, title, pitch, brief, repos }; | |
| 124 | const said = describe(before, after); | |
| 125 | const at = new Date().toISOString(); | |
| 126 | db.transaction(() => { | |
| 127 | if (before === undefined) | |
| 128 | db.query( | |
| 129 | `INSERT INTO curated_pack (name, title, pitch, brief, position, created_at, updated_at) | |
| 130 | VALUES (?1, ?2, ?3, ?4, (SELECT COALESCE(MAX(position), -1) + 1 FROM curated_pack), ?5, ?5)`, | |
| 131 | ).run(name, title, pitch, brief, at); | |
| 132 | else db.query("UPDATE curated_pack SET title = ?2, pitch = ?3, brief = ?4, updated_at = ?5 WHERE name = ?1").run(name, title, pitch, brief, at); | |
| 133 | db.query("DELETE FROM curated_pack_repo WHERE pack = ?1").run(name); | |
| 134 | for (const [i, r] of repos.entries()) db.query("INSERT INTO curated_pack_repo (pack, repo, position) VALUES (?1, ?2, ?3)").run(name, r, i); | |
| 135 | for (const what of said) db.query("INSERT INTO curated_pack_change (pack, actor, what, at) VALUES (?1, ?2, ?3, ?4)").run(name, user.username, what, at); | |
| 136 | })(); | |
| 137 | return { ok: true, pack: after }; | |
| 138 | } | |
| 139 | ||
| 140 | export type RepoOp = "add" | "remove" | "up" | "down"; | |
| 141 | export const isRepoOp = (v: unknown): v is RepoOp => v === "add" || v === "remove" || v === "up" || v === "down"; | |
| 142 | ||
| 143 | /** | |
| 144 | * One change to a pack's repo list, through `editPack`: the page and the terminal share it. | |
| 145 | * A repo to add must exist here; one to move or remove must be in the pack. | |
| 146 | */ | |
| 147 | export function changeRepos(db: Database, user: Admin | undefined, name: string, op: unknown, word: unknown): EditResult { | |
| 148 | if (!mayCurate(user)) return { ok: false, error: "forbidden", says: "only a site admin edits packs" }; | |
| 149 | const pack = getPack(db, name); | |
| 150 | if (pack === undefined) return { ok: false, error: "not_found", says: "no such pack" }; | |
| 151 | const repo = repoWord(word); | |
| 152 | if (repo === undefined) return bad("Write the repo as owner/name."); | |
| 153 | if (!isRepoOp(op)) return bad("Choose add, remove, up or down."); | |
| 154 | const repos = [...pack.repos]; | |
| 155 | const at = repos.indexOf(repo); | |
| 156 | if (op === "add") { | |
| 157 | const [owner = "", rname = ""] = repo.split("/"); | |
| 158 | if (getRepo(db, owner, rname) === undefined) return bad(`${repo} is not a repository on this forge.`); | |
| 159 | if (at < 0) repos.push(repo); | |
| 160 | } else if (at < 0) return bad(`${repo} is not in this pack.`); | |
| 161 | else if (op === "remove") repos.splice(at, 1); | |
| 162 | else if (op === "up" && at > 0) [repos[at - 1], repos[at]] = [repos[at]!, repos[at - 1]!]; | |
| 163 | else if (op === "down" && at < repos.length - 1) [repos[at + 1], repos[at]] = [repos[at]!, repos[at + 1]!]; | |
| 164 | return editPack(db, user, name, { repos }); | |
| 165 | } | |
| 166 | ||
| 167 | export interface PackRepo { | |
| 168 | readonly full: string; | |
| 169 | readonly description: string; | |
| 170 | /** Runs an app on this forge: it has a live link. */ | |
| 171 | readonly live: boolean; | |
| 172 | /** The newest push, or null. */ | |
| 173 | readonly pushedAt: string | null; | |
| 174 | /** Why it is not shown, when it is not: missing, or not public. */ | |
| 175 | readonly left?: string; | |
| 176 | } | |
| 177 | ||
| 178 | /** A pack's repos as `viewer` may see them, in the pack's order. Missing and private ones are named, not shown. One grouped read for dates, one for apps. */ | |
| 179 | export function resolveRepos(db: Database, pack: CuratedPack, viewer: User | undefined): PackRepo[] { | |
| 180 | const found = pack.repos.map((full) => { | |
| 181 | const [owner = "", name = ""] = full.split("/"); | |
| 182 | return { full, repo: getRepo(db, owner, name) }; | |
| 183 | }); | |
| 184 | const shown = found.flatMap((f) => (f.repo !== undefined && f.repo.visibility === "public" && canRead(db, f.repo, viewer) ? [f.repo] : [])); | |
| 185 | const ids = shown.map((r) => r.id); | |
| 186 | const dates = ids.length === 0 ? new Map() : repoDatesFor(db, ids); | |
| 187 | const live = new Set( | |
| 188 | ids.length === 0 | |
| 189 | ? [] | |
| 190 | : db.query<{ repo_id: string }, string[]>(`SELECT repo_id FROM app WHERE repo_id IN (${ids.map(() => "?").join(",")})`).all(...ids).map((r) => r.repo_id), | |
| 191 | ); | |
| 192 | return found.map(({ full, repo }): PackRepo => { | |
| 193 | if (repo === undefined) return { full, description: "", live: false, pushedAt: null, left: "not on this forge" }; | |
| 194 | if (!ids.includes(repo.id)) return { full, description: "", live: false, pushedAt: null, left: "not public" }; | |
| 195 | return { | |
| 196 | full: `${repo.ownerName}/${repo.name}`, | |
| 197 | description: repo.description, | |
| 198 | live: live.has(repo.id), | |
| 199 | pushedAt: dates.get(repo.id)?.pushedAt ?? null, | |
| 200 | }; | |
| 201 | }); | |
| 202 | } |
src/tui/screens/newforge.ts +2 −2
| @@ -64,8 +64,8 @@ function refusal(body: Record<string, unknown>): { key?: string; text: string } | ||
| 64 | 64 | return m && body["error"] === "invalid" ? { key: m[1]!, text: m[2]! } : { text }; |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | export function newForgeScreen(f: Session, ctx: Ctx): Screen { | |
| 68 | const values: Record<string, string> = {}; | |
| 67 | export function newForgeScreen(f: Session, ctx: Ctx, initial: Record<string, string> = {}): Screen { | |
| 68 | const values: Record<string, string> = { ...initial }; | |
| 69 | 69 | let focus = 0; |
| 70 | 70 | let errorKey: string | undefined; |
| 71 | 71 | let error: string | undefined; |
src/tui/screens/packs.ts +135 −14
| @@ -2,9 +2,14 @@ | ||
| 2 | 2 | * `/packs` in the terminal: the premade forges this forge ships, one pack's |
| 3 | 3 | * contents (apps, crew), the commands that deploy it and what happens to a |
| 4 | 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. | |
| 5 | * prints. Anyone may open it. The curated packs (#11006) are read from the | |
| 6 | * database and edited through `packs/curated.ts`, the page's own door. | |
| 6 | 7 | */ |
| 7 | 8 | |
| 9 | import { changeRepos, editPack, getPack, history, listPacks, mayCurate, resolveRepos, slugOf, type EditResult, type PackRepo } from "../../packs/curated.ts"; | |
| 10 | import { MAX_DIRECTION_CHARS } from "../../spawn/plan.ts"; | |
| 11 | import { newForgeScreen } from "./newforge.ts"; | |
| 12 | import { promptScreen } from "./common.ts"; | |
| 8 | 13 | import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts"; |
| 9 | 14 | import type { App } from "../../app.ts"; |
| 10 | 15 | import type { Pack } from "../../packs/pack.ts"; |
| @@ -62,38 +67,154 @@ function packScreen(ctx: Ctx, p: Pack, origin: string | undefined): Screen { | ||
| 62 | 67 | }; |
| 63 | 68 | } |
| 64 | 69 | |
| 65 | export function packsScreen(ctx: Ctx, origin: string | undefined, app?: App): Screen { | |
| 66 | if (app !== undefined) void refreshCatalogQuietly(app, 2_000).then(() => ctx.invalidate()); | |
| 70 | const says = (done: EditResult): string => (done.ok ? "Saved. /packs shows it now." : done.says); | |
| 71 | ||
| 72 | /** | |
| 73 | * One curated pack, read from the database on every draw (so an edit on the | |
| 74 | * page shows here at once, and an edit here is on the page the same way). | |
| 75 | * Everyone: j/k scroll, s starts a forge from it as it stands. A person admin | |
| 76 | * also: a add, x remove, u/d move the repo under the cursor, t/p/b title, | |
| 77 | * pitch, brief. Repos that are private or missing are named to admins only. | |
| 78 | */ | |
| 79 | export function curatedScreen(session: Session, ctx: Ctx, name: string): Screen { | |
| 67 | 80 | let at = 0; |
| 68 | return { | |
| 69 | title: "Forge packs", | |
| 70 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["esc"], "close")], | |
| 81 | let note = ""; | |
| 82 | const user = session.user; | |
| 83 | const db = session.app.db; | |
| 84 | const view = (): { repos: PackRepo[]; shown: PackRepo[] } | undefined => { | |
| 85 | const pack = getPack(db, name); | |
| 86 | if (pack === undefined) return undefined; | |
| 87 | const repos = resolveRepos(db, pack, user); | |
| 88 | return { repos: mayCurate(user) ? repos : repos.filter((r) => r.left === undefined), shown: repos.filter((r) => r.left === undefined) }; | |
| 89 | }; | |
| 90 | const wrote = (done: EditResult): void => { | |
| 91 | note = says(done); | |
| 92 | ctx.invalidate(); | |
| 93 | }; | |
| 94 | const edit = (label: string, field: "title" | "pitch" | "brief"): void => { | |
| 95 | const pack = getPack(db, name); | |
| 96 | if (pack === undefined || !mayCurate(user)) return; | |
| 97 | ctx.push(promptScreen(ctx, screen, { title: `Pack ${field}`, label, value: pack[field], onSubmit: (text) => { wrote(editPack(db, user, name, { [field]: text })); } })); | |
| 98 | }; | |
| 99 | const start = (): void => { | |
| 100 | const pack = getPack(db, name); | |
| 101 | const v = view(); | |
| 102 | if (pack === undefined || v === undefined) return; | |
| 103 | ctx.push(newForgeScreen(session, ctx, { apps: v.shown.map((r) => r.full).join(", "), direction: pack.brief.slice(0, MAX_DIRECTION_CHARS) })); | |
| 104 | }; | |
| 105 | const screen: Screen = { | |
| 106 | title: "Pack", | |
| 107 | keys: [binding(["j", "k"], "move"), binding(["s"], "start a forge"), ...(mayCurate(user) ? [binding(["a", "x", "u", "d", "t", "p", "b"], "edit")] : []), binding(["esc"], "back")], | |
| 71 | 108 | render(area, s) { |
| 72 | 109 | const c = ctx.theme.c; |
| 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); | |
| 74 | catalog().packs.forEach((p, i) => { | |
| 110 | const pack = getPack(db, name); | |
| 111 | const v = view(); | |
| 112 | if (pack === undefined || v === undefined) { | |
| 113 | s.text(area.x + 1, area.y, "This pack is gone.", style(c.mutedFg, c.bg), area.x + area.w); | |
| 114 | return; | |
| 115 | } | |
| 116 | at = Math.min(at, Math.max(0, v.repos.length - 1)); | |
| 117 | const lines: [string, "fg" | "muted" | "accent"][] = [ | |
| 118 | [`${pack.title} pack`, "fg"], | |
| 119 | ...wrap(pack.pitch, area.w - 4).map((l): [string, "muted"] => [l, "muted"]), | |
| 120 | ["", "fg"], | |
| 121 | [`${v.shown.length} repo${v.shown.length === 1 ? "" : "s"}. Press s to start a forge from this.`, "muted"], | |
| 122 | ["", "fg"], | |
| 123 | ...v.repos.map((r, i): [string, "fg" | "muted" | "accent"] => [`${i === at && mayCurate(user) ? "> " : " "}${r.full}${r.left === undefined ? (r.live ? " live" : "") : ` (${r.left})`}`, r.left === undefined ? (i === at ? "accent" : "fg") : "muted"]), | |
| 124 | ...(pack.brief === "" ? [] : ([["", "fg"], ["Crew brief", "fg"], ...wrap(pack.brief, area.w - 4).map((l): [string, "muted"] => [l, "muted"])] as [string, "fg" | "muted"][])), | |
| 125 | ...(mayCurate(user) | |
| 126 | ? ([["", "fg"], ["History", "fg"], ...history(db, name, 5).map((h): [string, "muted"] => [` ${h.actor} ${h.what}`, "muted"])] as [string, "fg" | "muted"][]) | |
| 127 | : []), | |
| 128 | ...(note === "" ? [] : ([["", "fg"], [note, "accent"]] as [string, "accent"][])), | |
| 129 | ]; | |
| 130 | lines.slice(0, area.h).forEach(([t, tone], i) => s.text(area.x + 1, area.y + i, truncate(t, area.w - 2), style(tone === "muted" ? c.mutedFg : tone === "accent" ? c.accent : c.fg, c.bg), area.x + area.w)); | |
| 131 | }, | |
| 132 | key(e) { | |
| 133 | if (e.ctrl || e.alt) return false; | |
| 134 | const v = view(); | |
| 135 | const mine = mayCurate(user); | |
| 136 | const row = v?.repos[at]; | |
| 137 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 138 | else if (e.key === "j" || e.key === "down") at = Math.min(Math.max(0, (v?.repos.length ?? 1) - 1), at + 1); | |
| 139 | else if (e.key === "k" || e.key === "up") at = Math.max(0, at - 1); | |
| 140 | else if (e.key === "s") start(); | |
| 141 | else if (mine && e.key === "a") | |
| 142 | ctx.push(promptScreen(ctx, screen, { title: "Add a repo", label: "owner/name", onSubmit: (text) => { wrote(changeRepos(db, user, name, "add", text)); } })); | |
| 143 | else if (mine && row !== undefined && (e.key === "x" || e.key === "u" || e.key === "d")) { | |
| 144 | const word = getPack(db, name)?.repos[at]; | |
| 145 | wrote(changeRepos(db, user, name, e.key === "x" ? "remove" : e.key === "u" ? "up" : "down", word)); | |
| 146 | if (e.key === "u") at = Math.max(0, at - 1); | |
| 147 | if (e.key === "d") at += 1; | |
| 148 | } else if (mine && e.key === "t") edit("Title", "title"); | |
| 149 | else if (mine && e.key === "p") edit("Pitch", "pitch"); | |
| 150 | else if (mine && e.key === "b") edit("Crew brief", "brief"); | |
| 151 | else return false; | |
| 152 | return true; | |
| 153 | }, | |
| 154 | }; | |
| 155 | return screen; | |
| 156 | } | |
| 157 | ||
| 158 | /** The list: curated packs first (Enter opens one, a person admin's n makes one), then the deployable pack.json ones. */ | |
| 159 | export function packsScreen(ctx: Ctx, origin: string | undefined, app?: App, session?: Session): Screen { | |
| 160 | if (app !== undefined) void refreshCatalogQuietly(app, 2_000).then(() => ctx.invalidate()); | |
| 161 | let at = 0; | |
| 162 | let note = ""; | |
| 163 | const curated = () => (app === undefined ? [] : listPacks(app.db)); | |
| 164 | const total = () => curated().length + catalog().packs.length; | |
| 165 | const mine = () => session !== undefined && mayCurate(session.user); | |
| 166 | const screen: Screen = { | |
| 167 | title: "Forge packs", | |
| 168 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), ...(mine() ? [binding(["n"], "new pack")] : []), binding(["esc"], "close")], | |
| 169 | render(area, s) { | |
| 170 | const c = ctx.theme.c; | |
| 171 | s.text(area.x + 1, area.y, "Packs: short lists of repos to start a forge from", style(c.mutedFg, c.bg), area.x + area.w); | |
| 172 | const rows = [ | |
| 173 | ...curated().map((p) => `${p.title} ${p.pitch}`), | |
| 174 | ...catalog().packs.map((p) => `${p.title} v${p.version} ${p.apps.length} apps, ${p.crew.length} crew ${p.description}`), | |
| 175 | ]; | |
| 176 | rows.forEach((text, i) => { | |
| 75 | 177 | const on = i === at; |
| 76 | const row = `${on ? "> " : " "}${p.title} v${p.version} ${p.apps.length} apps, ${p.crew.length} crew ${p.description}`; | |
| 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); | |
| 178 | if (2 + i < area.h) s.text(area.x + 1, area.y + 2 + i, truncate(`${on ? "> " : " "}${text}`, area.w - 2), on ? style(c.accent, c.bg) : style(c.fg, c.bg), area.x + area.w); | |
| 78 | 179 | }); |
| 180 | if (rows.length === 0) s.text(area.x + 1, area.y + 2, mine() ? "No packs yet. Press n to make one." : "No packs yet. An admin makes the first one.", style(c.mutedFg, c.bg), area.x + area.w); | |
| 181 | if (note !== "") s.text(area.x + 1, area.y + Math.min(area.h - 1, rows.length + 3), truncate(note, area.w - 2), style(c.accent, c.bg), area.x + area.w); | |
| 79 | 182 | }, |
| 80 | 183 | key(e) { |
| 81 | 184 | if (e.ctrl || e.alt) return false; |
| 82 | 185 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 83 | else if (e.key === "j" || e.key === "down") at = Math.min(catalog().packs.length - 1, at + 1); | |
| 186 | else if (e.key === "j" || e.key === "down") at = Math.min(Math.max(0, total() - 1), at + 1); | |
| 84 | 187 | else if (e.key === "k" || e.key === "up") at = Math.max(0, at - 1); |
| 188 | else if (e.key === "n" && session !== undefined && mine()) | |
| 189 | ctx.push( | |
| 190 | promptScreen(ctx, screen, { | |
| 191 | title: "New pack", | |
| 192 | label: "Title", | |
| 193 | onSubmit: (title) => { | |
| 194 | const done = editPack(session.app.db, session.user, slugOf(title), { title, repos: [] }, true); | |
| 195 | note = done.ok ? "Pack made. Open it to add repos." : done.says; | |
| 196 | ctx.invalidate(); | |
| 197 | }, | |
| 198 | }), | |
| 199 | ); | |
| 85 | 200 | else if (e.key === "enter") { |
| 86 | const p = catalog().packs[at]; | |
| 87 | if (p !== undefined) ctx.push(packScreen(ctx, p, origin)); | |
| 201 | const list = curated(); | |
| 202 | const c = list[at]; | |
| 203 | if (c !== undefined && session !== undefined) ctx.push(curatedScreen(session, ctx, c.name)); | |
| 204 | else { | |
| 205 | const p = catalog().packs[at - list.length]; | |
| 206 | if (p !== undefined) ctx.push(packScreen(ctx, p, origin)); | |
| 207 | } | |
| 88 | 208 | } else return false; |
| 89 | 209 | return true; |
| 90 | 210 | }, |
| 91 | 211 | }; |
| 212 | return screen; | |
| 92 | 213 | } |
| 93 | 214 | |
| 94 | 215 | export const packsCommand = (session: Session, ctx: Ctx) => ({ |
| 95 | 216 | id: "packs", |
| 96 | 217 | title: "Forge packs", |
| 97 | 218 | hint: "premade forges to deploy, and how to help build their apps", |
| 98 | run: () => ctx.push(packsScreen(ctx, session.app.config.url, session.app)), | |
| 219 | run: () => ctx.push(packsScreen(ctx, session.app.config.url, session.app, session)), | |
| 99 | 220 | }); |
src/web/pages/new-forge.ts +16 −1
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * shown once, in the answer's body — never in a URL, never cached. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { getPack, resolveRepos } from "../../packs/curated.ts"; | |
| 12 | 13 | import { listAgents } from "../../core/agents.ts"; |
| 13 | 14 | import { listReposVisibleTo } from "../../core/repos.ts"; |
| 14 | 15 | import type { User } from "../../core/users.ts"; |
| @@ -20,6 +21,7 @@ import { | ||
| 20 | 21 | LEFT_BEHIND, |
| 21 | 22 | MAX_FOR_CHARS, |
| 22 | 23 | MAX_FRESH_CHARS, |
| 24 | MAX_DIRECTION_CHARS, | |
| 23 | 25 | MAX_BUDGET_USD, |
| 24 | 26 | MIN_BUDGET_USD, |
| 25 | 27 | } from "../../spawn/plan.ts"; |
| @@ -440,6 +442,19 @@ function madeView(body: Record<string, unknown>): Html { | ||
| 440 | 442 | const asBool = (form: Form, name: string): boolean => |
| 441 | 443 | field(form, name) === "on"; |
| 442 | 444 | |
| 445 | /** | |
| 446 | * `?pack=name`: the pack as it stands now, as the form's starting values (its | |
| 447 | * public, readable repos ticked and its crew brief as the direction). The | |
| 448 | * form is then what is posted, so a later edit of the pack changes nothing | |
| 449 | * about a forge started from this one. | |
| 450 | */ | |
| 451 | function fromPack(ctx: Ctx, user: User | undefined): Partial<Values> { | |
| 452 | const pack = getPack(ctx.app.db, ctx.url.searchParams.get("pack") ?? ""); | |
| 453 | if (pack === undefined) return {}; | |
| 454 | const apps = resolveRepos(ctx.app.db, pack, user).filter((r) => r.left === undefined).map((r) => r.full); | |
| 455 | return { apps, direction: pack.brief.slice(0, MAX_DIRECTION_CHARS) }; | |
| 456 | } | |
| 457 | ||
| 443 | 458 | // GET /forges/new — any signed-in person, or a stranger (agents are refused). |
| 444 | 459 | export function newForgePage(ctx: Ctx): Response { |
| 445 | 460 | const user = ctx.auth.user; |
| @@ -450,7 +465,7 @@ export function newForgePage(ctx: Ctx): Response { | ||
| 450 | 465 | const response = page({ |
| 451 | 466 | ctx, |
| 452 | 467 | title: "New forge", |
| 453 | body: formView(ctx, user, { ...EMPTY, name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38) }, {}, null, reads), | |
| 468 | body: formView(ctx, user, { ...EMPTY, name: (ctx.url.searchParams.get("name") ?? "").slice(0, 38), ...fromPack(ctx, user) }, {}, null, reads), | |
| 454 | 469 | }); |
| 455 | 470 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 456 | 471 | return response; |
src/web/pages/packs.ts +179 −13
| @@ -12,13 +12,15 @@ import { notFoundPage, pageHead } from "../chrome.ts"; | ||
| 12 | 12 | import { deployCommands } from "../../packs/words.ts"; |
| 13 | 13 | import { html } from "../html.ts"; |
| 14 | 14 | import { flashTo, page } from "../layout.ts"; |
| 15 | import { readForm } from "../form.ts"; | |
| 16 | 15 | import { mirrorPacks, reportWords } from "../../packs/mirror.ts"; |
| 17 | import { btn, copyField, row } from "../ui.ts"; | |
| 16 | import { btn, copyField, field as uiField, row, timeAgo } from "../ui.ts"; | |
| 17 | import { listReposVisibleTo } from "../../core/repos.ts"; | |
| 18 | import { changeRepos, editPack, getPack, history, listPacks, mayCurate, resolveRepos, slugOf, type CuratedPack, type PackRepo } from "../../packs/curated.ts"; | |
| 19 | import { field, readForm } from "../form.ts"; | |
| 18 | 20 | |
| 19 | 21 | const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? "" : "s"}`; |
| 20 | 22 | |
| 21 | /** The list of packs as rows; each row is one j/k stop. */ | |
| 23 | /** The list of deployable packs (a pack.json in a repo) as rows; each row is one j/k stop. */ | |
| 22 | 24 | export const packsList = (packs: readonly Pack[]) => |
| 23 | 25 | html`<ul class="list">${packs.map((p) => |
| 24 | 26 | row({ |
| @@ -32,30 +34,159 @@ export const packsList = (packs: readonly Pack[]) => | ||
| 32 | 34 | /** A pack pushed to its repo shows within this long. */ |
| 33 | 35 | const CATALOG_FRESH_MS = 2_000; |
| 34 | 36 | |
| 37 | const admin = (ctx: Ctx): boolean => mayCurate(ctx.auth.user); | |
| 38 | ||
| 39 | /** The newest push among a pack's shown repos, as "3d ago", or a plain "no pushes yet". */ | |
| 40 | const freshness = (repos: readonly PackRepo[]): string => { | |
| 41 | const newest = repos.map((r) => r.pushedAt).filter((t): t is string => t !== null).sort().pop(); | |
| 42 | return newest === undefined ? "no pushes yet" : `updated ${timeAgo(newest)}`; | |
| 43 | }; | |
| 44 | ||
| 45 | /** One repo of a pack: its name, what it is, and the live link when it runs here. */ | |
| 46 | const repoRow = (r: PackRepo) => | |
| 47 | row({ | |
| 48 | title: html`<a href="/${r.full}">${r.full.split("/")[1]}</a>`, | |
| 49 | meta: html`${r.description === "" ? "No description yet." : r.description}`, | |
| 50 | trail: r.live ? html`<a href="/${r.full}/app/production/open" rel="nofollow">Open live</a>` : html`<span class="muted small">not running</span>`, | |
| 51 | }); | |
| 52 | ||
| 53 | /** The one accent of a pack's own page; on the list it is plain, so a page has one primary. */ | |
| 54 | const startButton = (pack: CuratedPack, primary: boolean) => | |
| 55 | btn("Start a forge from this", { href: `/forges/new?pack=${encodeURIComponent(pack.name)}`, ...(primary ? { variant: "primary" as const } : {}) }); | |
| 56 | ||
| 57 | function packCard(ctx: Ctx, pack: CuratedPack) { | |
| 58 | const repos = resolveRepos(ctx.app.db, pack, ctx.auth.user); | |
| 59 | const shown = repos.filter((r) => r.left === undefined); | |
| 60 | return html`<section class="stack" id="pack-${pack.name}"> | |
| 61 | <h2><a href="/packs/${pack.name}">${pack.title}</a></h2> | |
| 62 | <p>${pack.pitch}</p> | |
| 63 | <p class="muted small">${plural(shown.length, "repo")}, ${freshness(shown)}.</p> | |
| 64 | <ul class="list">${shown.slice(0, 4).map(repoRow)}</ul> | |
| 65 | ${shown.length > 4 ? html`<p class="muted small"><a href="/packs/${pack.name}">All ${shown.length} repos</a></p>` : null} | |
| 66 | <div class="form-actions">${startButton(pack, false)}</div> | |
| 67 | </section>`; | |
| 68 | } | |
| 69 | ||
| 70 | /** What a pack is, in one sentence. */ | |
| 71 | const WHAT_IS_A_PACK = "A pack is a short list of repos on this forge that start together: start a forge from one and you get a copy of each repo and a crew briefed for them."; | |
| 72 | ||
| 35 | 73 | // GET /packs |
| 36 | 74 | export async function packsPage(ctx: Ctx): Promise<Response> { |
| 37 | 75 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 38 | 76 | const started = performance.now(); |
| 39 | 77 | const { packs } = await refreshCatalogQuietly(ctx.app, CATALOG_FRESH_MS); |
| 40 | const body = html`${pageHead("Forge packs", { | |
| 41 | sub: "A whole forge to start from: real apps with their history, and a crew set up to work on them.", | |
| 42 | })} | |
| 43 | ${packsList(packs)} | |
| 44 | ${ctx.auth.user?.isAdmin === true && ctx.auth.user.kind !== "agent" | |
| 45 | ? html`<form class="stack" method="post" action="/packs/mirror"> | |
| 46 | <p class="muted small">Copy each pack's upstream projects here as read-only repositories that keep up with the original.</p> | |
| 47 | ${btn("Mirror the upstreams", { size: "sm", variant: "primary", label: "Mirror the packs' upstreams" })} | |
| 78 | const curated = listPacks(ctx.app.db); | |
| 79 | const body = html`${pageHead("Packs", { sub: WHAT_IS_A_PACK })} | |
| 80 | ${curated.length === 0 | |
| 81 | ? html`<section class="stack"> | |
| 82 | <h2>No packs yet</h2> | |
| 83 | ${admin(ctx) ? html`<p>Make one from the repos you already have.</p>` : html`<p class="muted">An admin makes the first one.</p>`} | |
| 84 | </section>` | |
| 85 | : html`<div class="stack">${curated.map((p) => packCard(ctx, p))}</div>`} | |
| 86 | ${admin(ctx) | |
| 87 | ? html`<form class="stack" method="post" action="/packs/new"> | |
| 88 | <h2>${curated.length === 0 ? "Make one from these repos" : "New pack"}</h2> | |
| 89 | ${uiField({ label: "Title", name: "title", required: true, placeholder: "Business" })} | |
| 90 | <div class="form-actions">${btn("New pack")}</div> | |
| 48 | 91 | </form>` |
| 49 | : null}`; | |
| 50 | const response = page({ ctx, title: "Forge packs", body }); | |
| 92 | : null} | |
| 93 | ${packs.length === 0 && !admin(ctx) | |
| 94 | ? null | |
| 95 | : html`<section class="stack"> | |
| 96 | <h2>Deploy on your own machine</h2> | |
| 97 | ${packsList(packs)} | |
| 98 | ${admin(ctx) | |
| 99 | ? html`<form class="stack" method="post" action="/packs/mirror"> | |
| 100 | <p class="muted small">Copy each of these packs' upstream projects here as read-only repositories that keep up with the original.</p> | |
| 101 | ${btn("Mirror the upstreams", { size: "sm", label: "Mirror the packs' upstreams" })} | |
| 102 | </form>` | |
| 103 | : null} | |
| 104 | </section>`}`; | |
| 105 | const response = page({ ctx, title: "Packs", body }); | |
| 51 | 106 | notePhase(ctx.request, "render", performance.now() - started); |
| 52 | 107 | return response; |
| 53 | 108 | } |
| 54 | 109 | |
| 110 | const stamp = (iso: string): string => iso.slice(0, 16).replace("T", " "); | |
| 111 | ||
| 112 | function editSection(ctx: Ctx, pack: CuratedPack, repos: readonly PackRepo[]) { | |
| 113 | const mine = new Set(pack.repos); | |
| 114 | const options = listReposVisibleTo(ctx.app.db, ctx.auth.user, { limit: 200, withoutArchived: true }) | |
| 115 | .filter((r) => r.visibility === "public" && !mine.has(`${r.ownerName}/${r.name}`.toLowerCase())); | |
| 116 | const action = `/packs/${pack.name}/repos`; | |
| 117 | return html`<section class="stack"> | |
| 118 | <h2>Edit this pack</h2> | |
| 119 | <form class="stack" method="post" action="/packs/${pack.name}/edit"> | |
| 120 | ${uiField({ label: "Title", name: "title", value: pack.title, required: true })} | |
| 121 | ${uiField({ label: "Pitch", name: "pitch", value: pack.pitch, hint: "One line: what you get." })} | |
| 122 | ${uiField({ label: "Crew brief", name: "brief", type: "textarea", rows: 4, value: pack.brief, hint: "What the crew of a forge started from this pack is told." })} | |
| 123 | <div class="form-actions">${btn("Save")}</div> | |
| 124 | </form> | |
| 125 | <h3>Repos, in the order the cards show</h3> | |
| 126 | <ul class="list">${repos.map((r, i) => | |
| 127 | row({ | |
| 128 | title: html`<span class="mono">${r.full}</span>`, | |
| 129 | meta: html`${r.left ?? (r.description === "" ? "Shown." : r.description)}`, | |
| 130 | trail: html`<form method="post" action="${action}" class="form-actions"> | |
| 131 | <input type="hidden" name="repo" value="${pack.repos[i]}" /> | |
| 132 | ${i > 0 ? btn("↑", { name: "op", value: "up", size: "sm", variant: "ghost", label: `Move ${r.full} up` }) : null} | |
| 133 | ${i < repos.length - 1 ? btn("↓", { name: "op", value: "down", size: "sm", variant: "ghost", label: `Move ${r.full} down` }) : null} | |
| 134 | ${btn("Remove", { name: "op", value: "remove", size: "sm", variant: "ghost", label: `Remove ${r.full}` })} | |
| 135 | </form>`, | |
| 136 | }), | |
| 137 | )}</ul> | |
| 138 | <form class="stack" method="post" action="${action}"> | |
| 139 | <input type="hidden" name="op" value="add" /> | |
| 140 | <label class="field"><span>Add a repo</span> | |
| 141 | <input class="input mono" name="repo" list="pack-repo-options" autocomplete="off" spellcheck="false" placeholder="Type to search: owner/name" required /> | |
| 142 | </label> | |
| 143 | <datalist id="pack-repo-options">${options.map((r) => html`<option value="${r.ownerName}/${r.name}"></option>`)}</datalist> | |
| 144 | <div class="form-actions">${btn("Add")}</div> | |
| 145 | </form> | |
| 146 | </section>`; | |
| 147 | } | |
| 148 | ||
| 149 | function curatedPage(ctx: Ctx, pack: CuratedPack, legacy: Pack | undefined): Response { | |
| 150 | const repos = resolveRepos(ctx.app.db, pack, ctx.auth.user); | |
| 151 | const shown = repos.filter((r) => r.left === undefined); | |
| 152 | const left = repos.filter((r) => r.left !== undefined); | |
| 153 | const past = admin(ctx) ? history(ctx.app.db, pack.name) : []; | |
| 154 | const commands = legacy === undefined ? [] : deployCommands(legacy, ctx.app.config.url); | |
| 155 | const body = html`${pageHead(`${pack.title} pack`, { sub: pack.pitch })} | |
| 156 | <p class="muted small">${plural(shown.length, "repo")}, ${freshness(shown)}.</p> | |
| 157 | <div class="form-actions">${startButton(pack, true)}</div> | |
| 158 | ${legacy === undefined | |
| 159 | ? null | |
| 160 | : html`<section class="stack"> | |
| 161 | <h2>Deploy it on your own machine</h2> | |
| 162 | ${copyField("pack-command", commands.join("\n"), `The command that deploys the ${legacy.title} pack`, "Copy the command", { lines: commands.length })} | |
| 163 | <p class="muted small">No data, secrets or variables come with it.</p> | |
| 164 | </section>`} | |
| 165 | <section class="stack"> | |
| 166 | <h2>The apps inside</h2> | |
| 167 | <ul class="list">${shown.map(repoRow)}</ul> | |
| 168 | ${left.length === 0 || !admin(ctx) ? null : html`<p class="muted small">Left out for now: ${left.map((r) => `${r.full} (${r.left})`).join(", ")}.</p>`} | |
| 169 | </section> | |
| 170 | ${pack.brief === "" ? null : html`<section class="stack"><h2>The crew it starts with</h2><p>${pack.brief}</p></section>`} | |
| 171 | ${admin(ctx) ? editSection(ctx, pack, repos) : null} | |
| 172 | ${past.length === 0 | |
| 173 | ? null | |
| 174 | : html`<section class="stack"><h2>History</h2> | |
| 175 | <ul class="list">${past.map((c) => row({ title: html`${c.actor} ${c.what}`, meta: html`${stamp(c.at)}` }))}</ul></section>`}`; | |
| 176 | return page({ ctx, title: `${pack.title} pack`, body }); | |
| 177 | } | |
| 178 | ||
| 55 | 179 | // GET /packs/:name |
| 56 | 180 | export async function packPage(ctx: Ctx): Promise<Response> { |
| 57 | 181 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 58 | 182 | const started = performance.now(); |
| 183 | const curated = getPack(ctx.app.db, ctx.params.name ?? ""); | |
| 184 | if (curated !== undefined) { | |
| 185 | const legacy = (await refreshCatalogQuietly(ctx.app, CATALOG_FRESH_MS)).packs.find((p) => p.name === curated.name); | |
| 186 | const shown = curatedPage(ctx, curated, legacy); | |
| 187 | notePhase(ctx.request, "render", performance.now() - started); | |
| 188 | return shown; | |
| 189 | } | |
| 59 | 190 | const pack = (await refreshCatalogQuietly(ctx.app, CATALOG_FRESH_MS)).packs.find((p) => p.name === (ctx.params.name ?? "")); |
| 60 | 191 | if (pack === undefined) return notFoundPage(ctx); |
| 61 | 192 | const commands = deployCommands(pack, ctx.app.config.url); |
| @@ -95,3 +226,38 @@ export async function packsMirror(ctx: Ctx): Promise<Response> { | ||
| 95 | 226 | if (!done.ok) return notFoundPage(ctx); |
| 96 | 227 | return flashTo(ctx, "/packs", { tone: done.value.failed.length > 0 ? "danger" : "success", message: reportWords(done.value) }); |
| 97 | 228 | } |
| 229 | ||
| 230 | const refused = (ctx: Ctx, to: string, says: string): Response => flashTo(ctx, to, { tone: "danger", message: says }); | |
| 231 | ||
| 232 | // POST /packs/new (admin) | |
| 233 | export async function packNew(ctx: Ctx): Promise<Response> { | |
| 234 | if (!admin(ctx)) return notFoundPage(ctx); | |
| 235 | const form = await readForm(ctx, "admin"); | |
| 236 | if (form instanceof Response) return form; | |
| 237 | const title = field(form, "title"); | |
| 238 | const name = slugOf(title); | |
| 239 | const done = editPack(ctx.app.db, ctx.auth.user, name, { title, repos: [] }, true); | |
| 240 | return done.ok ? flashTo(ctx, `/packs/${name}`, { tone: "success", message: "Pack made. Add its repos below." }) : refused(ctx, "/packs", done.says); | |
| 241 | } | |
| 242 | ||
| 243 | // POST /packs/:name/edit (admin) | |
| 244 | export async function packEdit(ctx: Ctx): Promise<Response> { | |
| 245 | if (!admin(ctx)) return notFoundPage(ctx); | |
| 246 | const form = await readForm(ctx, "admin"); | |
| 247 | if (form instanceof Response) return form; | |
| 248 | const name = ctx.params.name ?? ""; | |
| 249 | const done = editPack(ctx.app.db, ctx.auth.user, name, { title: field(form, "title"), pitch: field(form, "pitch"), brief: field(form, "brief") }); | |
| 250 | return done.ok ? flashTo(ctx, `/packs/${name}`, { tone: "success", message: "Saved." }) : refused(ctx, `/packs/${name}`, done.says); | |
| 251 | } | |
| 252 | ||
| 253 | // POST /packs/:name/repos (admin): add, remove, up, down | |
| 254 | export async function packRepos(ctx: Ctx): Promise<Response> { | |
| 255 | if (!admin(ctx)) return notFoundPage(ctx); | |
| 256 | const form = await readForm(ctx, "admin"); | |
| 257 | if (form instanceof Response) return form; | |
| 258 | const name = ctx.params.name ?? ""; | |
| 259 | const here = `/packs/${name}`; | |
| 260 | if (getPack(ctx.app.db, name) === undefined) return notFoundPage(ctx); | |
| 261 | const done = changeRepos(ctx.app.db, ctx.auth.user, name, field(form, "op"), field(form, "repo")); | |
| 262 | return done.ok ? flashTo(ctx, here, { tone: "success", message: "Saved." }) : refused(ctx, here, done.says); | |
| 263 | } |
test/now-links.test.ts +1 −1
| @@ -111,7 +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 | Packs: "Packs", | |
| 115 | 115 | Displays: "Displays", |
| 116 | 116 | Map: "Map", |
| 117 | 117 | "New app": "New app", |
test/packs-curated.test.ts added +175
| @@ -0,0 +1,175 @@ | ||
| 1 | /** Curated packs (#11006): seeded, shown as cards, edited by an admin, started from as they stand, and curated over the API. */ | |
| 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 { createRepo } from "../src/core/repos.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { editPack, getPack, history, slugOf } from "../src/packs/curated.ts"; | |
| 12 | import { adminCookie } from "./helpers/admin.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-cur-")); | |
| 22 | app = createApp({ dataDir: dir, port: 0 }); | |
| 23 | servers = await serve(app); | |
| 24 | admin = await adminCookie(app, { username: "trevato", 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 | const owner = getUserByName(app.db, "trevato")!; | |
| 29 | for (const name of ["chess", "pong", "bazaar"]) { | |
| 30 | const made = await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name, visibility: "public", description: `${name} game`, defaultBranch: "main" }); | |
| 31 | if (!made.ok) throw new Error(made.error); | |
| 32 | } | |
| 33 | await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "secret-one", visibility: "private", description: "", defaultBranch: "main" }); | |
| 34 | }); | |
| 35 | afterAll(async () => { | |
| 36 | await servers.stop(); | |
| 37 | app.close(); | |
| 38 | await rm(dir, { recursive: true, force: true }); | |
| 39 | }); | |
| 40 | ||
| 41 | const post = (path: string, cookie: string, fields: Record<string, string>, origin = servers.url) => | |
| 42 | fetch(`${servers.url}${path}`, { | |
| 43 | method: "POST", | |
| 44 | redirect: "manual", | |
| 45 | headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin }, | |
| 46 | body: new URLSearchParams(fields), | |
| 47 | }); | |
| 48 | const text = async (path: string, cookie = "") => await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); | |
| 49 | ||
| 50 | test("the forge starts with a Business and a Gamer pack, as cards with a start button", async () => { | |
| 51 | const names = ["business", "gamer"]; | |
| 52 | for (const n of names) expect(getPack(app.db, n)).toBeDefined(); | |
| 53 | const page = await text("/packs"); | |
| 54 | expect(page).toContain("A pack is a short list of repos"); | |
| 55 | expect(page.split("A pack is a short list of repos").length).toBe(2); // said once | |
| 56 | expect(page).not.toMatch(/class="card[" ]/); // a heading and rows, not a box around each pack | |
| 57 | expect(page.match(/data-variant="primary"/g) ?? []).toHaveLength(0); // one accent per page, and the list has none | |
| 58 | const own = await text("/packs/gamer", admin); | |
| 59 | expect(own.match(/data-variant="primary"/g) ?? []).toHaveLength(1); // the pack's own page: Start is the one, Save and Add are plain | |
| 60 | expect(page).toContain(`href="/packs/gamer"`); | |
| 61 | expect(page).toContain(`href="/forges/new?pack=gamer"`); | |
| 62 | expect(page).toContain("Start a forge from this"); | |
| 63 | expect(page).toContain("chess"); | |
| 64 | expect(page).not.toContain("/chess/app/production/open"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("a repo not on this forge or not public is left out: named to an admin, never to a visitor", async () => { | |
| 68 | const visitor = await text("/packs/gamer"); | |
| 69 | expect(visitor).not.toContain("Left out for now"); | |
| 70 | expect(visitor).not.toContain("trevato/voxel"); | |
| 71 | const page = await text("/packs/gamer", admin); | |
| 72 | expect(page).toContain("Left out for now"); | |
| 73 | expect(page).toContain("trevato/voxel (not on this forge)"); | |
| 74 | }); | |
| 75 | ||
| 76 | test("an unknown op is refused and says so, and changes nothing", async () => { | |
| 77 | const before = getPack(app.db, "gamer")!.repos.slice(); | |
| 78 | const res = await post("/packs/gamer/repos", admin, { op: "explode", repo: before[0]! }); | |
| 79 | expect(res.status).toBe(303); | |
| 80 | expect(getPack(app.db, "gamer")!.repos).toEqual(before); | |
| 81 | expect(history(app.db, "gamer")[0]?.what).not.toBe("saved"); | |
| 82 | const flash = await fetch(`${servers.url}${res.headers.get("location")}`, { headers: { cookie: admin + "; " + (res.headers.get("set-cookie") ?? "").split(";")[0] } }); | |
| 83 | expect(await flash.text()).not.toContain("Saved."); | |
| 84 | }); | |
| 85 | ||
| 86 | test("only an admin sees Edit; a non-admin's and a cross-origin edit are refused", async () => { | |
| 87 | expect(await text("/packs/gamer", plain)).not.toContain("Edit this pack"); | |
| 88 | expect(await text("/packs/gamer", admin)).toContain("Edit this pack"); | |
| 89 | expect((await post("/packs/gamer/repos", plain, { op: "remove", repo: "trevato/chess" })).status).toBe(404); | |
| 90 | expect((await post("/packs/gamer/repos", admin, { op: "remove", repo: "trevato/chess" }, "https://evil.example")).status).toBeGreaterThanOrEqual(400); | |
| 91 | expect(getPack(app.db, "gamer")!.repos).toContain("trevato/chess"); | |
| 92 | }); | |
| 93 | ||
| 94 | test("add, move and remove a repo; each change is kept with who and when", async () => { | |
| 95 | expect((await post("/packs/gamer/repos", admin, { op: "remove", repo: "trevato/pong" })).status).toBe(303); | |
| 96 | expect(getPack(app.db, "gamer")!.repos).not.toContain("trevato/pong"); | |
| 97 | await post("/packs/gamer/repos", admin, { op: "add", repo: "trevato/pong" }); | |
| 98 | expect(getPack(app.db, "gamer")!.repos.at(-1)).toBe("trevato/pong"); | |
| 99 | await post("/packs/gamer/repos", admin, { op: "up", repo: "trevato/pong" }); | |
| 100 | const repos = getPack(app.db, "gamer")!.repos; | |
| 101 | expect(repos.indexOf("trevato/pong")).toBe(repos.length - 2); | |
| 102 | const past = history(app.db, "gamer"); | |
| 103 | expect(past[0]!.what).toBe("reordered the repos"); | |
| 104 | expect(past.some((c) => c.what === "removed trevato/pong" && c.actor === "trevato")).toBe(true); | |
| 105 | expect(await text("/packs/gamer", admin)).toContain("trevato removed trevato/pong"); | |
| 106 | // a repo that is not on the forge cannot be added | |
| 107 | await post("/packs/gamer/repos", admin, { op: "add", repo: "nobody/nothing" }); | |
| 108 | expect(getPack(app.db, "gamer")!.repos).not.toContain("nobody/nothing"); | |
| 109 | }); | |
| 110 | ||
| 111 | test("rename, pitch and crew brief save and show", async () => { | |
| 112 | await post("/packs/business/edit", admin, { title: "Small business", pitch: "Money in.", brief: "Watch the leads." }); | |
| 113 | const page = await text("/packs/business"); | |
| 114 | expect(page).toContain("Small business pack"); | |
| 115 | expect(page).toContain("Money in."); | |
| 116 | expect(page).toContain("Watch the leads."); | |
| 117 | }); | |
| 118 | ||
| 119 | test("a new pack is made from /packs and shows", async () => { | |
| 120 | const res = await post("/packs/new", admin, { title: "Chess club" }); | |
| 121 | expect(res.status).toBe(303); | |
| 122 | expect(res.headers.get("location")).toContain("/packs/chess-club"); | |
| 123 | expect(slugOf("Chess club!")).toBe("chess-club"); | |
| 124 | expect(await text("/packs")).toContain("Chess club"); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a forge started from a pack copies what the form posted, not what the pack is by then", async () => { | |
| 128 | const form = await text("/forges/new?pack=gamer", admin); | |
| 129 | const apps = [...form.matchAll(/<input[^>]*name="apps"[^>]*value="([^"]+)"[^>]*checked|<input[^>]*value="([^"]+)"[^>]*name="apps"[^>]*checked/g)].map((m) => m[1] ?? m[2]!); | |
| 130 | expect(apps).toContain("trevato/chess"); | |
| 131 | expect(form).toContain("play feel"); | |
| 132 | // the pack changes after the form was opened | |
| 133 | await post("/packs/gamer/repos", admin, { op: "remove", repo: "trevato/chess" }); | |
| 134 | expect(getPack(app.db, "gamer")!.repos).not.toContain("trevato/chess"); | |
| 135 | // posting the form plans the forge it held: the removed repo is still in the plan | |
| 136 | const planned = await fetch(`${servers.url}/forges/new`, { | |
| 137 | method: "POST", | |
| 138 | headers: { cookie: admin, "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 139 | body: new URLSearchParams([["name", "from-pack"], ["do", "plan"], ...apps.map((a) => ["apps", a] as [string, string])]), | |
| 140 | }); | |
| 141 | const plan = await planned.text(); | |
| 142 | expect(plan.slice(plan.indexOf('id="plan"'))).toContain("trevato/chess"); | |
| 143 | // and a new open reads the pack as it is now | |
| 144 | expect(await text("/forges/new?pack=gamer", admin)).not.toMatch(/value="trevato\/chess"[^>]*checked/); | |
| 145 | }); | |
| 146 | ||
| 147 | test("the API reads and curates packs; a plain user cannot write", async () => { | |
| 148 | const get = (await (await fetch(`${servers.url}/api/v1/packs/business`)).json()) as { name: string }; | |
| 149 | expect(get.name).toBe("business"); | |
| 150 | const put = (cookie: string, payload: unknown) => | |
| 151 | fetch(`${servers.url}/api/v1/packs/api-made`, { method: "PUT", headers: { cookie, "content-type": "application/json", origin: servers.url }, body: JSON.stringify(payload) }); | |
| 152 | expect((await put(plain, { title: "X", repos: [] })).status).toBe(403); | |
| 153 | const made = await put(admin, { title: "Made by API", pitch: "p", brief: "b", repos: ["trevato/bazaar", "trevato/secret-one", "bad"] }); | |
| 154 | expect(made.status).toBe(422); | |
| 155 | const ok = await put(admin, { title: "Made by API", pitch: "p", brief: "b", repos: ["trevato/bazaar", "trevato/secret-one"] }); | |
| 156 | expect(ok.status).toBe(201); | |
| 157 | const body = (await ok.json()) as { repos: string[]; shown: string[]; left_out: { repo: string; why: string }[] }; | |
| 158 | expect(body.repos).toEqual(["trevato/bazaar", "trevato/secret-one"]); // the admin gets the list as kept | |
| 159 | expect(body.shown).toEqual(["trevato/bazaar"]); // a private repo is never shown, even to its owner's pack | |
| 160 | expect(body.left_out).toEqual([{ repo: "trevato/secret-one", why: "not public" }]); | |
| 161 | // nobody else learns the private name: not anonymous, not a plain user, on the item or the list | |
| 162 | for (const cookie of ["", plain]) { | |
| 163 | for (const path of ["/api/v1/packs/api-made", "/api/v1/packs"]) { | |
| 164 | const raw = await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); | |
| 165 | expect(raw).not.toContain("secret-one"); | |
| 166 | } | |
| 167 | const seen = (await (await fetch(`${servers.url}/api/v1/packs/api-made`, { headers: { cookie } })).json()) as Record<string, unknown>; | |
| 168 | expect(seen["shown"]).toEqual(["trevato/bazaar"]); | |
| 169 | expect(seen["repos"]).toBeUndefined(); | |
| 170 | } | |
| 171 | const again = await put(admin, { repos: ["trevato/chess"] }); | |
| 172 | expect(again.status).toBe(200); | |
| 173 | expect(getPack(app.db, "api-made")!.title).toBe("Made by API"); | |
| 174 | expect(editPack(app.db, undefined, "x", {}, true).ok).toBe(false); | |
| 175 | }); |
test/packs-settings.test.ts +6 −3
| @@ -45,8 +45,11 @@ const listed = async () => await (await fetch(`${servers.url}/packs`)).text(); | ||
| 45 | 45 | |
| 46 | 46 | test("nothing is marked, so /packs lists no pack even though the repos exist", async () => { |
| 47 | 47 | const text = await listed(); |
| 48 | // the deployable packs (a pack.json in a marked repo) are listed apart from the curated cards | |
| 49 | expect(text).not.toContain("Deploy on your own machine"); | |
| 48 | 50 | expect(text).not.toContain(`href="/packs/games"`); |
| 49 | expect(text).not.toContain(`href="/packs/business"`); | |
| 51 | // the seeded curated Business pack is a page of its own, with no deploy command: it is not a marked pack.json | |
| 52 | expect(text).toContain(`href="/packs/business"`); | |
| 50 | 53 | }); |
| 51 | 54 | |
| 52 | 55 | test("only an admin sees the page or saves; a cross-origin post is refused", async () => { |
| @@ -61,8 +64,8 @@ test("a save lists exactly the marked repo, at once, and keeps the change", asyn | ||
| 61 | 64 | expect(res.status).toBe(303); |
| 62 | 65 | const text = await listed(); |
| 63 | 66 | 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); | |
| 67 | expect(await (await fetch(`${servers.url}/packs/business`)).text()).not.toContain("scripts/pack.ts deploy business"); | |
| 68 | expect((await fetch(`${servers.url}/packs/games`)).status).toBe(200); | |
| 66 | 69 | const changes = app.db.query<{ to_value: string }, []>("SELECT to_value FROM forge_setting_change WHERE key = 'packs.repos'").all(); |
| 67 | 70 | expect(changes.at(-1)!.to_value).toBe('["packs/games"]'); |
| 68 | 71 | const page = await (await fetch(`${servers.url}/settings/packs`, { headers: { cookie: admin } })).text(); |
test/packs-tui.test.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** Curated packs in the terminal (#11006): the same gate and the same door as the page. */ | |
| 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 { createRepo } from "../src/core/repos.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { getPack } from "../src/packs/curated.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import { curatedScreen, packsScreen } from "../src/tui/screens/packs.ts"; | |
| 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 15 | import { adminCookie } from "./helpers/admin.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | let dir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let admin: User; | |
| 22 | let bob: User; | |
| 23 | let adminHeader: string; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dir = await mkdtemp(join(tmpdir(), "forge-packs-tui-")); | |
| 27 | app = createApp({ dataDir: dir, port: 0 }); | |
| 28 | servers = await serve(app); | |
| 29 | adminHeader = await adminCookie(app, { username: "trevato", email: "[email protected]", password: "a-good-passphrase" }); | |
| 30 | admin = getUserByName(app.db, "trevato")!; | |
| 31 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 32 | if (!made.ok) throw new Error(made.error); | |
| 33 | bob = made.value; | |
| 34 | createSession(app.db, bob.id); | |
| 35 | for (const [name, visibility] of [["chess", "public"], ["pong", "public"], ["secret-one", "private"]] as const) { | |
| 36 | const r = await createRepo(app.db, app.reposRoot, { ownerId: admin.id, name, visibility, description: `${name} game`, defaultBranch: "main" }); | |
| 37 | if (!r.ok) throw new Error(r.error); | |
| 38 | } | |
| 39 | }); | |
| 40 | afterAll(async () => { | |
| 41 | await servers.stop(); | |
| 42 | app.close(); | |
| 43 | await rm(dir, { recursive: true, force: true }); | |
| 44 | }); | |
| 45 | ||
| 46 | const key = (k: string) => ({ type: "key" as const, key: k, ctrl: false, alt: false, shift: false }); | |
| 47 | function open(user: User, make: (session: never, ctx: never) => ReturnType<typeof curatedScreen>) { | |
| 48 | const { app: tui, session } = createForgeApp( | |
| 49 | app, | |
| 50 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, | |
| 51 | { terminal: headlessTerminal(120, 40), exitHooks: false }, | |
| 52 | ); | |
| 53 | tui.start(); | |
| 54 | const screen = make(session as never, tui.ctx as never); | |
| 55 | tui.ctx.push(screen); | |
| 56 | return { tui, screen }; | |
| 57 | } | |
| 58 | const web = async (path: string, cookie = "") => await (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text(); | |
| 59 | ||
| 60 | test("browse: a guest sees the public repos and never a private or missing name; an admin sees why they are left out", async () => { | |
| 61 | const set = (repos: string[]) => app.db.transaction(() => { | |
| 62 | app.db.query("DELETE FROM curated_pack_repo WHERE pack = 'gamer'").run(); | |
| 63 | repos.forEach((r, i) => app.db.query("INSERT INTO curated_pack_repo (pack, repo, position) VALUES ('gamer', ?1, ?2)").run(r, i)); | |
| 64 | })(); | |
| 65 | set(["trevato/chess", "trevato/secret-one", "trevato/nowhere"]); | |
| 66 | const guest = open(bob, (s, c) => curatedScreen(s, c, "gamer")); | |
| 67 | await settleApp(guest.tui); | |
| 68 | const seen = guest.tui.screen.toText(); | |
| 69 | expect(seen).toContain("trevato/chess"); | |
| 70 | expect(seen).not.toContain("secret-one"); | |
| 71 | expect(seen).not.toContain("nowhere"); | |
| 72 | expect(seen).not.toContain("History"); | |
| 73 | expect(guest.screen.key(key("x"))).toBe(false); // a guest has no edit keys | |
| 74 | expect(await web("/packs/gamer")).not.toContain("secret-one"); | |
| 75 | const boss = open(admin, (s, c) => curatedScreen(s, c, "gamer")); | |
| 76 | await settleApp(boss.tui); | |
| 77 | expect(boss.tui.screen.toText()).toContain("trevato/secret-one (not public)"); | |
| 78 | expect(boss.tui.screen.toText()).toContain("trevato/nowhere (not on this forge)"); | |
| 79 | guest.tui.stop?.(); | |
| 80 | boss.tui.stop?.(); | |
| 81 | }); | |
| 82 | ||
| 83 | test("edit: an admin's keys add, move and remove a repo, and the page shows it at once; a guest's do nothing", async () => { | |
| 84 | const boss = open(admin, (s, c) => curatedScreen(s, c, "business")); | |
| 85 | await settleApp(boss.tui); | |
| 86 | const before = getPack(app.db, "business")!.repos; | |
| 87 | boss.screen.key(key("x")); // the cursor starts on the first repo | |
| 88 | expect(getPack(app.db, "business")!.repos).toEqual(before.slice(1)); | |
| 89 | boss.screen.key(key("d")); | |
| 90 | const moved = getPack(app.db, "business")!.repos; | |
| 91 | expect(moved.slice(0, 2)).toEqual([before[2]!, before[1]!]); | |
| 92 | expect(await web("/packs/business", adminHeader)).toContain(`trevato removed ${before[0]!}`); | |
| 93 | const guest = open(bob, (s, c) => curatedScreen(s, c, "business")); | |
| 94 | for (const k of ["x", "u", "d", "a", "t"]) guest.screen.key(key(k)); | |
| 95 | expect(getPack(app.db, "business")!.repos).toEqual(moved); | |
| 96 | boss.screen.key(key("a")); | |
| 97 | await settleApp(boss.tui); | |
| 98 | expect(boss.tui.screen.toText()).toContain("Add a repo"); | |
| 99 | boss.tui.stop?.(); | |
| 100 | guest.tui.stop?.(); | |
| 101 | }); | |
| 102 | ||
| 103 | test("start: s opens the new-forge form with the pack's shown repos and brief, and only those", async () => { | |
| 104 | const guest = open(bob, (s, c) => curatedScreen(s, c, "gamer")); | |
| 105 | guest.screen.key(key("s")); | |
| 106 | await settleApp(guest.tui); | |
| 107 | const text = guest.tui.screen.toText(); | |
| 108 | expect(text).toContain("trevato/chess"); | |
| 109 | expect(text).not.toContain("secret-one"); | |
| 110 | expect(text).toContain("how it plays now"); | |
| 111 | guest.tui.stop?.(); | |
| 112 | }); | |
| 113 | ||
| 114 | test("the list shows the curated packs, and n makes one for an admin only", async () => { | |
| 115 | const boss = open(admin, (s, c) => packsScreen(c as never, undefined, (s as { app: App }).app, s as never) as never); | |
| 116 | await settleApp(boss.tui); | |
| 117 | expect(boss.tui.screen.toText()).toContain("Gamer"); | |
| 118 | expect(boss.screen.key(key("n"))).toBe(true); | |
| 119 | await settleApp(boss.tui); | |
| 120 | expect(boss.tui.screen.toText()).toContain("New pack"); | |
| 121 | const guest = open(bob, (s, c) => packsScreen(c as never, undefined, (s as { app: App }).app, s as never) as never); | |
| 122 | expect(guest.screen.key(key("n"))).toBe(false); | |
| 123 | boss.tui.stop?.(); | |
| 124 | guest.tui.stop?.(); | |
| 125 | }); |
test/packs.test.ts +1 −1
| @@ -245,7 +245,7 @@ describe("pages", () => { | ||
| 245 | 245 | expect(cmd).toBeLessThan(page.indexOf("What it brings")); |
| 246 | 246 | const list = await (await fetch(`${servers.url}/packs`)).text(); |
| 247 | 247 | expect(list).toContain('class="list"'); |
| 248 | expect(list).not.toMatch(/class="card[" ]/); | |
| 248 | expect(list).not.toMatch(/class="card[" ]/); // curated packs are a heading and rows, not cards (#11006) | |
| 249 | 249 | expect(list).toContain('class="row"'); |
| 250 | 250 | }); |
| 251 | 251 | }); |
HIGH:
repos: p.reposexposes private and missing repo names to anonymous callers. Returnshownonly, unless the caller is a person admin.