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

Merged opened this in answer to

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

Merged

scout/forge:scout/11006-packs-is-an-empty-page-show-real-packs-y → main 5 commits fork 1 thread · 1 unresolved

  • Merged. scout/11006-packs-is-an-empty-page-show-real-packs-y was merged into main as 61eef6d.

Review

Sign in to review this change.
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
2121 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.
2222
2323 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";
104104 export { route as getApiV1OrgsOrg } from "./getApiV1OrgsOrg.ts";
105105 export { route as getApiV1OrgsOrgTeams } from "./getApiV1OrgsOrgTeams.ts";
106106 export { route as getApiV1OrgsOrgTeamsTeamMembers } from "./getApiV1OrgsOrgTeamsTeamMembers.ts";
107 export { route as getApiV1Packs } from "./getApiV1Packs.ts";
108 export { route as getApiV1PacksName } from "./getApiV1PacksName.ts";
107109 export { route as getApiV1PassportHealth } from "./getApiV1PassportHealth.ts";
108110 export { route as getApiV1Releases } from "./getApiV1Releases.ts";
109111 export { route as getApiV1Repos } from "./getApiV1Repos.ts";
@@ -374,6 +376,7 @@ export { route as putApiV1FeaturesSlug } from "./putApiV1FeaturesSlug.ts";
374376 export { route as putApiV1ForgeEconomics } from "./putApiV1ForgeEconomics.ts";
375377 export { route as putApiV1ForgesNameCredit } from "./putApiV1ForgesNameCredit.ts";
376378 export { route as putApiV1Front } from "./putApiV1Front.ts";
379 export { route as putApiV1PacksName } from "./putApiV1PacksName.ts";
377380 export { route as putApiV1ReposOwnerNameAppEnvVar } from "./putApiV1ReposOwnerNameAppEnvVar.ts";
378381 export { route as putApiV1ReposOwnerNameAppMembers } from "./putApiV1ReposOwnerNameAppMembers.ts";
379382 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";
347347 import { egressGrantRoutes } from "./api/egress_grants.ts";
348348 import { printRoutes } from "./api/print.ts";
349349 import { forgeRoutes } from "./api/forges.ts";
350 import { packRoutes } from "./api/packs.ts";
350351 import { forgeViewRoutes } from "./api/forge_view.ts";
351352 import { economicsRoutes } from "./api/economics.ts";
352353 import { releasesRoutes } from "./api/releases.ts";
@@ -3573,6 +3574,7 @@ export function apiRoutes(router: Router<Handler>): void {
35733574 });
35743575
35753576 forgeRoutes(router);
3577 packRoutes(router);
35763578 forgeViewRoutes(router);
35773579 economicsRoutes(router);
35783580 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),
outdated

HIGH: repos: p.repos exposes private and missing repo names to anonymous callers. Return shown only, unless the caller is a person admin.

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";
291291 import { serviceWorker } from "../web/sw.ts";
292292 import { explorePage } from "../web/studio/explore.ts";
293293 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";
295295 import {
296296 makeItYoursPage,
297297 makeItYoursReceipt,
@@ -846,6 +846,9 @@ export function buildRouter(): Router<Handler> {
846846 // Forge packs: premade forges to deploy, and the way to help build their apps.
847847 router.add("GET", "/packs", packsPage);
848848 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);
849852 router.add("GET", "/packs/:name", packPage);
850853 router.add("POST", "/front", frontUpdate);
851854 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 }
6464 return m && body["error"] === "invalid" ? { key: m[1]!, text: m[2]! } : { text };
6565 }
6666
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 };
6969 let focus = 0;
7070 let errorKey: string | undefined;
7171 let error: string | undefined;
src/tui/screens/packs.ts +135 −14
@@ -2,9 +2,14 @@
22 * `/packs` in the terminal: the premade forges this forge ships, one pack's
33 * contents (apps, crew), the commands that deploy it and what happens to a
44 * 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.
67 */
78
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";
813 import { catalog, refreshCatalogQuietly } from "../../packs/catalog.ts";
914 import type { App } from "../../app.ts";
1015 import type { Pack } from "../../packs/pack.ts";
@@ -62,38 +67,154 @@ function packScreen(ctx: Ctx, p: Pack, origin: string | undefined): Screen {
6267 };
6368 }
6469
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 {
6780 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")],
71108 render(area, s) {
72109 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) => {
75177 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);
78179 });
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);
79182 },
80183 key(e) {
81184 if (e.ctrl || e.alt) return false;
82185 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);
84187 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 );
85200 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 }
88208 } else return false;
89209 return true;
90210 },
91211 };
212 return screen;
92213 }
93214
94215 export const packsCommand = (session: Session, ctx: Ctx) => ({
95216 id: "packs",
96217 title: "Forge packs",
97218 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)),
99220 });
src/web/pages/new-forge.ts +16 −1
@@ -9,6 +9,7 @@
99 * shown once, in the answer's body — never in a URL, never cached.
1010 */
1111
12 import { getPack, resolveRepos } from "../../packs/curated.ts";
1213 import { listAgents } from "../../core/agents.ts";
1314 import { listReposVisibleTo } from "../../core/repos.ts";
1415 import type { User } from "../../core/users.ts";
@@ -20,6 +21,7 @@ import {
2021 LEFT_BEHIND,
2122 MAX_FOR_CHARS,
2223 MAX_FRESH_CHARS,
24 MAX_DIRECTION_CHARS,
2325 MAX_BUDGET_USD,
2426 MIN_BUDGET_USD,
2527 } from "../../spawn/plan.ts";
@@ -440,6 +442,19 @@ function madeView(body: Record<string, unknown>): Html {
440442 const asBool = (form: Form, name: string): boolean =>
441443 field(form, name) === "on";
442444
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
443458 // GET /forges/new — any signed-in person, or a stranger (agents are refused).
444459 export function newForgePage(ctx: Ctx): Response {
445460 const user = ctx.auth.user;
@@ -450,7 +465,7 @@ export function newForgePage(ctx: Ctx): Response {
450465 const response = page({
451466 ctx,
452467 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),
454469 });
455470 notePhase(ctx.request, "render", performance.now() - rendering);
456471 return response;
src/web/pages/packs.ts +179 −13
@@ -12,13 +12,15 @@ import { notFoundPage, pageHead } from "../chrome.ts";
1212 import { deployCommands } from "../../packs/words.ts";
1313 import { html } from "../html.ts";
1414 import { flashTo, page } from "../layout.ts";
15 import { readForm } from "../form.ts";
1615 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";
1820
1921 const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? "" : "s"}`;
2022
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. */
2224 export const packsList = (packs: readonly Pack[]) =>
2325 html`<ul class="list">${packs.map((p) =>
2426 row({
@@ -32,30 +34,159 @@ export const packsList = (packs: readonly Pack[]) =>
3234 /** A pack pushed to its repo shows within this long. */
3335 const CATALOG_FRESH_MS = 2_000;
3436
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
3573 // GET /packs
3674 export async function packsPage(ctx: Ctx): Promise<Response> {
3775 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
3876 const started = performance.now();
3977 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>
4891 </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 });
51106 notePhase(ctx.request, "render", performance.now() - started);
52107 return response;
53108 }
54109
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
55179 // GET /packs/:name
56180 export async function packPage(ctx: Ctx): Promise<Response> {
57181 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
58182 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 }
59190 const pack = (await refreshCatalogQuietly(ctx.app, CATALOG_FRESH_MS)).packs.find((p) => p.name === (ctx.params.name ?? ""));
60191 if (pack === undefined) return notFoundPage(ctx);
61192 const commands = deployCommands(pack, ctx.app.config.url);
@@ -95,3 +226,38 @@ export async function packsMirror(ctx: Ctx): Promise<Response> {
95226 if (!done.ok) return notFoundPage(ctx);
96227 return flashTo(ctx, "/packs", { tone: done.value.failed.length > 0 ? "danger" : "success", message: reportWords(done.value) });
97228 }
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/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();
4545
4646 test("nothing is marked, so /packs lists no pack even though the repos exist", async () => {
4747 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");
4850 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"`);
5053 });
5154
5255 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
6164 expect(res.status).toBe(303);
6265 const text = await listed();
6366 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);
6669 const changes = app.db.query<{ to_value: string }, []>("SELECT to_value FROM forge_setting_change WHERE key = 'packs.repos'").all();
6770 expect(changes.at(-1)!.to_value).toBe('["packs/games"]');
6871 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", () => {
245245 expect(cmd).toBeLessThan(page.indexOf("What it brings"));
246246 const list = await (await fetch(`${servers.url}/packs`)).text();
247247 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)
249249 expect(list).toContain('class="row"');
250250 });
251251 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs