Public: anyone can read this; the crew can change it.
Admins mark repositories as app templates, and New app offers a picker when more than one is marked #10634
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10571-admins-mark-repositories-as-app-template → main 6 commits fork 7 threads · 7 unresolved
docs/design/attention-budget.json +2 −2
| @@ -109,12 +109,12 @@ | ||
| 109 | 109 | }, |
| 110 | 110 | "settings": { |
| 111 | 111 | "390": { |
| 112 | "words": 138, | |
| 112 | "words": 139, | |
| 113 | 113 | "blocks": 32, |
| 114 | 114 | "colours": 1 |
| 115 | 115 | }, |
| 116 | 116 | "1280": { |
| 117 | "words": 138, | |
| 117 | "words": 139, | |
| 118 | 118 | "blocks": 32, |
| 119 | 119 | "colours": 1 |
| 120 | 120 | } |
docs/design/audit.md +1
| @@ -114,6 +114,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 114 | 114 | - `/settings/resilience` — `pages/domains.ts` · failure domains and their last drill · `/resilience` · none seen. |
| 115 | 115 | - `/settings/backups` — `pages/backups.ts` (621 lines) · backups and restore · `/settings/resilience`, `/releases` · none seen. |
| 116 | 116 | - `/settings/flags` — `pages/flags.ts` · the forge's switches · none · none seen. |
| 117 | - `/settings/app-templates` — `pages/app_templates.ts` · which repositories New app offers as templates · `/apps/new` · none seen. | |
| 117 | 118 | - `/settings/features` — `pages/features.ts` · apps absorbed as the forge's features, and their settings · `/apps` (the same apps, as apps) · none seen. |
| 118 | 119 | - `/settings/packs` — `pages/packs-settings.ts` · the repositories /packs lists, one textarea and Save · `/packs`, `/settings/features` · none seen. |
| 119 | 120 | - `/settings/model` — `pages/model.ts` · which model the crew runs on · `/settings/crew` · none seen. |
docs/features/app-templates.md added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | # App templates | |
| 2 | ||
| 3 | A site admin marks repositories as app templates at `/settings/app-templates` (Settings → App templates), or with `POST /api/v1/admin/app-templates` `{ "repo": "owner/name", "marked": true }` (admin scope; anyone else gets 403). The marking is the `app_template` table. `trevato/app-template` is marked by default, comes first, and cannot be unmarked. | |
| 4 | ||
| 5 | New app (`/apps/new`, and the terminal's New app) shows a "Start from" picker when more than one marked template is readable by the caller; with one, nothing changes. `GET /api/v1/app-templates` lists what the caller is offered. A template the caller cannot read is never listed, and naming it (`template` on `POST /api/v1/apps`, `?template=`) answers 404. | |
| 6 | ||
| 7 | Code: `src/core/app_templates.ts` (read), `src/actions/app_templates.ts` (mark). |
src/actions/app_templates.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** An admin marks or unmarks a repository as an app template; everyone else is refused (`forbidden`); an unknown name is `no_such_template`. */ | |
| 2 | ||
| 3 | import type { App } from "../app.ts"; | |
| 4 | import { DEFAULT_TEMPLATE, setTemplateMark } from "../core/app_templates.ts"; | |
| 5 | import { getRepo } from "../core/repos.ts"; | |
| 6 | import type { User } from "../core/users.ts"; | |
| 7 | import { parseTemplateName } from "../apps/template_tip.ts"; | |
| 8 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 9 | ||
| 10 | export type MarkTemplateError = "forbidden" | "no_such_template" | "default_template"; | |
| 11 | ||
| 12 | export const MARK_TEMPLATE_ERRORS: Record<MarkTemplateError, string> = { | |
| 13 | forbidden: "Only a site admin marks app templates.", | |
| 14 | no_such_template: "No repository by that name. Name it owner/name.", | |
| 15 | default_template: `${DEFAULT_TEMPLATE} is always offered first.`, | |
| 16 | }; | |
| 17 | ||
| 18 | export function markTemplate( | |
| 19 | app: App, | |
| 20 | actor: User | undefined, | |
| 21 | named: string, | |
| 22 | marked: boolean, | |
| 23 | ): Result<{ name: string; marked: boolean }, MarkTemplateError> { | |
| 24 | if (!actor || !actor.isAdmin || actor.kind !== "user") return err("forbidden"); | |
| 25 | const parsed = parseTemplateName(named); | |
| 26 | const repo = parsed && getRepo(app.db, parsed.owner, parsed.name); | |
| 27 | if (!repo) return err("no_such_template"); | |
| 28 | const name = `${repo.ownerName}/${repo.name}`; | |
| 29 | if (!marked && name === DEFAULT_TEMPLATE) return err("default_template"); | |
| 30 | setTemplateMark(app.db, repo, marked, actor.id); | |
| 31 | app.bus.append({ | |
| 32 | kind: marked ? "app_template.marked" : "app_template.unmarked", | |
| 33 | actorId: actor.id, | |
| 34 | payload: { repo: name }, | |
| 35 | }); | |
| 36 | return ok({ name, marked }); | |
| 37 | } |
src/contract/openapi/routes/getApiV1AppTemplates.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/app-templates", | |
| 6 | summary: | |
| 7 | "The repositories New app offers as templates that you can read, `trevato/app-template` first; answers { templates: [\"owner/name\"] }", | |
| 8 | auth: true, | |
| 9 | refusals: {}, | |
| 10 | }; |
src/contract/openapi/routes/index.ts +2
| @@ -53,6 +53,7 @@ export { route as getApiV1AdminTeams } from "./getApiV1AdminTeams.ts"; | ||
| 53 | 53 | export { route as getApiV1AdminTrafficProbe } from "./getApiV1AdminTrafficProbe.ts"; |
| 54 | 54 | export { route as getApiV1Agents } from "./getApiV1Agents.ts"; |
| 55 | 55 | export { route as getApiV1AgentsUsername } from "./getApiV1AgentsUsername.ts"; |
| 56 | export { route as getApiV1AppTemplates } from "./getApiV1AppTemplates.ts"; | |
| 56 | 57 | export { route as getApiV1Apps } from "./getApiV1Apps.ts"; |
| 57 | 58 | export { route as getApiV1Asked } from "./getApiV1Asked.ts"; |
| 58 | 59 | export { route as getApiV1Asks } from "./getApiV1Asks.ts"; |
| @@ -229,6 +230,7 @@ export { route as postApiV1AdminAccountsUsernameRole } from "./postApiV1AdminAcc | ||
| 229 | 230 | export { route as postApiV1AdminAccountsUsernameSuspend } from "./postApiV1AdminAccountsUsernameSuspend.ts"; |
| 230 | 231 | export { route as postApiV1AdminAccountsUsernameUnsuspend } from "./postApiV1AdminAccountsUsernameUnsuspend.ts"; |
| 231 | 232 | export { route as postApiV1AdminAccountsUsernameVerify } from "./postApiV1AdminAccountsUsernameVerify.ts"; |
| 233 | export { route as postApiV1AdminAppTemplates } from "./postApiV1AdminAppTemplates.ts"; | |
| 232 | 234 | export { route as postApiV1AdminInvites } from "./postApiV1AdminInvites.ts"; |
| 233 | 235 | export { route as postApiV1AdminMaintenancePrepare } from "./postApiV1AdminMaintenancePrepare.ts"; |
| 234 | 236 | export { route as postApiV1AdminMaintenanceResume } from "./postApiV1AdminMaintenanceResume.ts"; |
src/contract/openapi/routes/postApiV1AdminAppTemplates.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: "/api/v1/admin/app-templates", | |
| 6 | summary: | |
| 7 | "Mark or unmark a repository as an app template (site admin, admin scope); send { repo: \"owner/name\", marked: true|false }; the default template cannot be unmarked", | |
| 8 | auth: true, | |
| 9 | refusals: { | |
| 10 | forbidden: "the caller is not a site admin", | |
| 11 | invalid_body: "repo and marked are required", | |
| 12 | default_template: "the default template is always offered", | |
| 13 | }, | |
| 14 | }; |
src/core/app_templates.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * The repositories offered as app templates. A site admin marks or unmarks | |
| 3 | * one (a row in `app_template`); `trevato/app-template` is marked by default | |
| 4 | * and always comes first, the rest follow in the order they were marked. | |
| 5 | * `offeredTemplates` lists only those the caller can read, so a private | |
| 6 | * template is never named to someone who cannot see it. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { canRead, getRepo, getRepoById, type Repo } from "./repos.ts"; | |
| 11 | import type { User } from "./users.ts"; | |
| 12 | ||
| 13 | /** The template every forge offers first, marked without a row. */ | |
| 14 | export const DEFAULT_TEMPLATE = "trevato/app-template"; | |
| 15 | ||
| 16 | /** Marked templates in offer order: the default (when it exists), then by when marked. */ | |
| 17 | export function markedTemplates(db: Database): Repo[] { | |
| 18 | const rows = db | |
| 19 | .query<{ repo_id: string }, []>("SELECT repo_id FROM app_template ORDER BY marked_at, repo_id") | |
| 20 | .all(); | |
| 21 | const out: Repo[] = []; | |
| 22 | const seen = new Set<string>(); | |
| 23 | const [owner = "", name = ""] = DEFAULT_TEMPLATE.split("/"); | |
| 24 | const first = getRepo(db, owner, name); | |
| 25 | if (first) { | |
| 26 | out.push(first); | |
| 27 | seen.add(first.id); | |
| 28 | } | |
| 29 | for (const r of rows) { | |
| 30 | if (seen.has(r.repo_id)) continue; | |
| 31 | const repo = getRepoById(db, r.repo_id); | |
| 32 | if (repo) { | |
| 33 | out.push(repo); | |
| 34 | seen.add(repo.id); | |
| 35 | } | |
| 36 | } | |
| 37 | return out; | |
| 38 | } | |
| 39 | ||
| 40 | /** The marked templates `actor` can read, as `owner/name`, default first. */ | |
| 41 | export function offeredTemplates(db: Database, actor: User | undefined): string[] { | |
| 42 | return markedTemplates(db) | |
| 43 | .filter((r) => canRead(db, r, actor)) | |
| 44 | .map((r) => `${r.ownerName}/${r.name}`); | |
| 45 | } | |
| 46 | ||
| 47 | /** Mark (`true`) or unmark (`false`) a repository. Refusing the default's unmarking is the action's job. */ | |
| 48 | export function setTemplateMark(db: Database, repo: Repo, marked: boolean, actorId: string): void { | |
outdated
Dead code: isMarkedTemplate has no caller. Remove it. |
||
| 49 | if (marked) { | |
| 50 | db.query("INSERT OR IGNORE INTO app_template (repo_id, marked_by) VALUES (?1, ?2)").run(repo.id, actorId); | |
| 51 | } else { | |
| 52 | db.query("DELETE FROM app_template WHERE repo_id = ?1").run(repo.id); | |
outdated
Comment says the default's marking cannot be taken off, but this function does not enforce it (the action does). Fix the comment. |
||
| 53 | } | |
| 54 | } |
src/core/event_audience.ts +3 −1
| @@ -244,7 +244,9 @@ export function classifiedAudience(kind: string): EventAudience | undefined { | ||
| 244 | 244 | kind.startsWith("release.") || |
| 245 | 245 | // A file held against new work is its admins' book-keeping (#6522). |
| 246 | 246 | kind === "repo.file_held" || |
| 247 | kind === "repo.file_released" | |
| 247 | kind === "repo.file_released" || | |
| 248 | // Which repositories New app offers is the admins' choice (`actions/app_templates.ts`). | |
| 249 | kind.startsWith("app_template.") | |
| 248 | 250 | ) |
| 249 | 251 | return "admins"; |
| 250 | 252 | // A broken adapter sync is for those who may write the repository (`apps/pull.ts`). |
src/core/migration-pins/20261110100000-app-template.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | cec8df5c0d0be8206ed2db8c448c30ef269b3d258d83dff8a1323b3b5fe4163a |
src/core/migrations/20261110100000-app-template.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | // Repositories a site admin has marked as app templates: New app offers them. | |
| 2 | // One row per marked repository; the default (`trevato/app-template`) needs no | |
| 3 | // row. Additive. | |
| 4 | export default ` | |
| 5 | CREATE TABLE app_template ( | |
| 6 | repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE, | |
| 7 | marked_by TEXT REFERENCES user(id) ON DELETE SET NULL, | |
| 8 | marked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 9 | ); | |
| 10 | `; |
src/http/api.ts +5
| @@ -359,7 +359,9 @@ import { businessPlanRoutes } from "./api/business_plan.ts"; | ||
| 359 | 359 | import { tokenRoutes } from "./api/tokens.ts"; |
| 360 | 360 | import { accountAdmin, adminRoutes } from "./api/admin.ts"; |
| 361 | 361 | import { maintenanceRoutes } from "./api/maintenance.ts"; |
| 362 | import { TEMPLATE_ERRORS } from "../apps/template_tip.ts"; | |
| 362 | 363 | import { flagsRoutes } from "./api/flags.ts"; |
| 364 | import { appTemplateRoutes } from "./api/app_templates.ts"; | |
| 363 | 365 | import { issueRoutes, labelsHandler } from "./api/issues.ts"; |
| 364 | 366 | import { FORGE_NOT_CARRIED } from "../origins/words.ts"; |
| 365 | 367 | import { forgeOriginRoutes } from "./api/forge_origin.ts"; |
| @@ -1245,6 +1247,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 1245 | 1247 | adminRoutes(router); |
| 1246 | 1248 | maintenanceRoutes(router); |
| 1247 | 1249 | flagsRoutes(router); |
| 1250 | appTemplateRoutes(router); | |
| 1248 | 1251 | |
| 1249 | 1252 | // Open read hooks (#1789): the owner, or an agent holding `telemetry:read`. |
| 1250 | 1253 | // Anyone else is refused; every agent read is logged. |
| @@ -3632,6 +3635,8 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3632 | 3635 | ctx.auth.user?.username || |
| 3633 | 3636 | "", |
| 3634 | 3637 | ); |
| 3638 | // A template the caller cannot read answers as one that does not exist. | |
| 3639 | if (made.error === "no_such_template") return apiError("no_such_template", 404, TEMPLATE_ERRORS.no_such_template); | |
| 3635 | 3640 | return respondAccess(made.error, 422); |
| 3636 | 3641 | } |
| 3637 | 3642 | return json( |
src/http/api/app_templates.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** | |
| 2 | * App templates over the API. `GET /api/v1/app-templates` lists the marked | |
| 3 | * templates the caller can read, default first. `POST /api/v1/admin/app-templates` | |
| 4 | * with `{ repo: "owner/name", marked: true|false }` marks or unmarks one: a | |
| 5 | * site admin only, anyone else signed in is told 403. | |
| 6 | */ | |
| 7 | ||
| 8 | import { MARK_TEMPLATE_ERRORS, markTemplate } from "../../actions/app_templates.ts"; | |
| 9 | import { offeredTemplates } from "../../core/app_templates.ts"; | |
| 10 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 11 | import type { Handler } from "../context.ts"; | |
| 12 | import { apiError, body, json, notFound, requireScope, unauthorized } from "../respond.ts"; | |
| 13 | import type { Router } from "../router.ts"; | |
| 14 | ||
| 15 | export function appTemplateRoutes(router: Router<Handler>): void { | |
| 16 | router.add("GET", "/api/v1/app-templates", (ctx) => { | |
| 17 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 18 | if (!ctx.auth.user) return unauthorized(); | |
| 19 | return json({ templates: offeredTemplates(ctx.app.db, ctx.auth.user) }); | |
| 20 | }); | |
| 21 | ||
| 22 | router.add("POST", "/api/v1/admin/app-templates", async (ctx) => { | |
| 23 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 24 | const user = ctx.auth.user; | |
| 25 | if (!user) return unauthorized(); | |
| 26 | if (!user.isAdmin || user.kind !== "user") return apiError("forbidden", 403, MARK_TEMPLATE_ERRORS.forbidden); | |
| 27 | const scoped = requireScope(ctx, "admin"); | |
| 28 | if (scoped) return scoped; | |
| 29 | const input = await body(ctx.request); | |
| 30 | if (typeof input["repo"] !== "string" || typeof input["marked"] !== "boolean") | |
| 31 | return apiError("invalid_body", 422, "send repo (owner/name) and marked (true or false)"); | |
| 32 | const done = markTemplate(ctx.app, user, input["repo"], input["marked"]); | |
| 33 | if (done.ok) return json(done.value); | |
| 34 | if (done.error === "no_such_template") return notFound(); | |
| 35 | return apiError(done.error, 422, MARK_TEMPLATE_ERRORS[done.error]); | |
| 36 | }); | |
| 37 | } |
src/http/settings_routes.ts +3
| @@ -16,6 +16,7 @@ import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; | ||
| 16 | 16 | import { teamPage, teamSet, teamsPage, teamsStream } from "../web/pages/teams.ts"; |
| 17 | 17 | import { telemetryPage, telemetrySet } from "../web/pages/telemetry.ts"; |
| 18 | 18 | import { flagsPage, flagsSet } from "../web/pages/flags.ts"; |
| 19 | import { appTemplatesPage, appTemplatesSet } from "../web/pages/app_templates.ts"; | |
| 19 | 20 | import { economicsPage, economicsSet } from "../web/pages/economics.ts"; |
| 20 | 21 | import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts"; |
| 21 | 22 | import { usagePage } from "../web/pages/usage.ts"; |
| @@ -204,6 +205,8 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 204 | 205 | router.add("POST", "/settings/features/:slug/unabsorb", featureSwitch(false)); |
| 205 | 206 | router.add("POST", "/settings/features/:slug/accept", featureAccept); |
| 206 | 207 | router.add("POST", "/settings/flags", flagsSet); |
| 208 | router.add("GET", "/settings/app-templates", appTemplatesPage); | |
| 209 | router.add("POST", "/settings/app-templates", appTemplatesSet); | |
| 207 | 210 | router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns)); |
| 208 | 211 | router.add("GET", "/settings/spend", spendPage); |
| 209 | 212 | router.add("GET", "/settings/traffic", trafficPage); |
src/tui/screens/app_templates.ts added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | /** | |
| 2 | * App templates in the terminal, a forge admin's: the repositories New app | |
| 3 | * offers, default first, as Settings → App templates lists them. `m` asks for | |
| 4 | * an `owner/name` and marks it; `u` unmarks the one under the cursor after a | |
| 5 | * confirm. Both go through `markTemplate`, so the refusals are the page's and | |
| 6 | * the API's words. Anyone who is not an admin (or is an agent) is offered no | |
| 7 | * entry and marks nothing. | |
| 8 | */ | |
| 9 | ||
| 10 | import { MARK_TEMPLATE_ERRORS, markTemplate } from "../../actions/app_templates.ts"; | |
| 11 | import { DEFAULT_TEMPLATE, markedTemplates } from "../../core/app_templates.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import type { PaletteEntry } from "./entries.ts"; | |
| 14 | import { binding } from "../app/keys.ts"; | |
| 15 | import { style } from "../term/style.ts"; | |
| 16 | import { wrap } from "../term/width.ts"; | |
| 17 | import { fill, len, split } from "../ui/layout.ts"; | |
| 18 | import type { Session } from "../session.ts"; | |
| 19 | import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts"; | |
| 20 | ||
| 21 | const mayMark = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind === "user"; | |
| 22 | ||
| 23 | export function appTemplatesScreen(f: Session, ctx: Ctx): Screen { | |
| 24 | const state = new ListState(); | |
| 25 | let rows: { name: string; visibility: string }[] = []; | |
| 26 | let note = ""; | |
| 27 | const reload = (): void => { | |
| 28 | rows = mayMark(f) | |
| 29 | ? markedTemplates(f.db).map((r) => ({ name: `${r.ownerName}/${r.name}`, visibility: r.visibility })) | |
| 30 | : []; | |
| 31 | state.clamp(rows.length); | |
| 32 | ctx.invalidate(); | |
| 33 | }; | |
| 34 | reload(); | |
| 35 | const change = (named: string, marked: boolean): string | undefined => { | |
| 36 | const done = markTemplate(f.app, f.user, named, marked); | |
| 37 | note = done.ok ? `${done.value.name} is ${marked ? "marked" : "unmarked"}.` : MARK_TEMPLATE_ERRORS[done.error]; | |
| 38 | reload(); | |
| 39 | return done.ok ? undefined : note; | |
| 40 | }; | |
| 41 | ||
| 42 | const mark = (): Screen => | |
| 43 | promptScreen(ctx, screen, { | |
| 44 | title: "Mark a template", | |
| 45 | label: "Which repository?", | |
| 46 | placeholder: "owner/name", | |
| 47 | onSubmit: (named) => change(named.trim(), true), | |
| 48 | }); | |
| 49 | const unmark = (name: string): Screen => | |
| 50 | confirmScreen(ctx, screen, { | |
| 51 | title: "Unmark this template", | |
| 52 | body: [name, "New app stops offering it."], | |
| 53 | actions: [{ label: "Keep", variant: "ghost" }, { label: "Unmark", variant: "danger" }], | |
| 54 | onChoose: (i) => { | |
| 55 | if (i === 1) change(name, false); | |
| 56 | }, | |
| 57 | }); | |
| 58 | ||
| 59 | const lines = (): ListRow[] => | |
| 60 | rows.map((r) => ({ | |
| 61 | title: r.name, | |
| 62 | meta: r.name === DEFAULT_TEMPLATE ? "always offered, first" : r.visibility === "private" ? "private" : "", | |
| 63 | })); | |
| 64 | ||
| 65 | const screen: Screen = { | |
| 66 | title: "App templates", | |
| 67 | keys: [binding(["m"], "mark one"), binding(["u"], "unmark"), binding(["j", "k"], "move cursor")], | |
| 68 | render(area, s) { | |
| 69 | const c = ctx.theme.c; | |
| 70 | const text = note !== "" ? note : mayMark(f) ? `${rows.length} offered by New app.` : MARK_TEMPLATE_ERRORS.forbidden; | |
| 71 | const said = wrap(text, Math.max(1, area.w)).slice(0, 3); | |
| 72 | const [head, list] = split(area, "v", [len(said.length), fill(1)]); | |
| 73 | said.forEach((line, i) => s.text(head!.x, head!.y + i, line, style(c.mutedFg, c.bg), head!.x + head!.w)); | |
| 74 | drawList(s, list!, c, state, lines()); | |
| 75 | }, | |
| 76 | key(e) { | |
| 77 | if (state.key(e, rows.length)) return true; | |
| 78 | if (e.ctrl || e.alt || !mayMark(f)) return false; | |
| 79 | if (e.key === "m") { | |
| 80 | note = ""; | |
| 81 | ctx.push(mark()); | |
| 82 | return true; | |
| 83 | } | |
| 84 | const r = rows[state.index]; | |
| 85 | if (e.key === "u" && r !== undefined) { | |
| 86 | note = ""; | |
| 87 | ctx.push(unmark(r.name)); | |
| 88 | return true; | |
| 89 | } | |
| 90 | return false; | |
| 91 | }, | |
| 92 | }; | |
| 93 | return screen; | |
| 94 | } | |
| 95 | ||
| 96 | /** Offered to a forge admin who is not an agent. */ | |
| 97 | export const entry: PaletteEntry = { | |
| 98 | id: "app-templates", | |
| 99 | title: "App templates", | |
| 100 | hint: "the repositories New app offers; mark or unmark one", | |
| 101 | after: ["economics"], | |
| 102 | offered: (session) => mayMark(session), | |
| 103 | run: (session, ctx) => ctx.push(appTemplatesScreen(session, ctx)), | |
| 104 | }; |
src/tui/screens/entries.ts +2
| @@ -13,6 +13,7 @@ import { entry as agents } from "./agents.ts"; | ||
| 13 | 13 | import { entry as askData } from "./ask_data.ts"; |
| 14 | 14 | import { entry as archived } from "./archived.ts"; |
| 15 | 15 | import { entry as audit } from "./audit.ts"; |
| 16 | import { entry as appTemplates } from "./app_templates.ts"; | |
| 16 | 17 | import { entry as bets } from "./bets.ts"; |
| 17 | 18 | import { entry as boards } from "./boards.ts"; |
| 18 | 19 | import { entry as bugs } from "./bugs.ts"; |
| @@ -115,6 +116,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 115 | 116 | quotas, |
| 116 | 117 | limits, |
| 117 | 118 | economics, |
| 119 | appTemplates, | |
| 118 | 120 | flowsAll, |
| 119 | 121 | scrapes, |
| 120 | 122 | trafficScreenEntry, |
src/tui/screens/newapp.ts +16
| @@ -11,6 +11,7 @@ import { appLimitWords } from "../../actions/app_limit.ts"; | ||
| 11 | 11 | import { NEW_APP_ERRORS, NO_NAME_ERROR, newAppFromSentence } from "../../actions/apps.ts"; |
| 12 | 12 | import { repositoryLimitMessage } from "../../actions/repos.ts"; |
| 13 | 13 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 14 | import { offeredTemplates } from "../../core/app_templates.ts"; | |
| 14 | 15 | import { listOrgsForUser } from "../../core/orgs.ts"; |
| 15 | 16 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 16 | 17 | import { parseTemplateName } from "../../apps/template_tip.ts"; |
| @@ -34,6 +35,21 @@ function refusal(f: Session, code: string, ownerId: string): string { | ||
| 34 | 35 | * as `/apps/new?template=` does; the action refuses one the person cannot read. |
| 35 | 36 | */ |
| 36 | 37 | export function newAppScreen(f: Session, ctx: Ctx, parent: Screen, template?: string): Screen { |
| 38 | // With no template named and more than one offered, ask which first. | |
No test covers this picker, and the terminal has no way to mark templates (both doors). Add both. |
||
| 39 | if (template === undefined && f.user !== undefined) { | |
| 40 | const offered = offeredTemplates(f.db, f.user); | |
| 41 | if (offered.length > 1) | |
| 42 | return promptScreen(ctx, parent, { | |
| 43 | title: "New app", | |
| 44 | label: `Start from which template? ${offered.join(", ")}`, | |
| 45 | value: offered[0]!, | |
| 46 | onSubmit: (named) => { | |
| 47 | const pick = named.trim(); | |
| 48 | if (!offered.includes(pick)) return `Pick one of: ${offered.join(", ")}.`; | |
| 49 | return later(ctx, () => newAppScreen(f, ctx, parent, pick)); | |
| 50 | }, | |
| 51 | }); | |
| 52 | } | |
| 37 | 53 | const owners = f.user |
| 38 | 54 | ? [f.user.username, ...listOrgsForUser(f.db, f.user.id).map((o) => o.username)] |
| 39 | 55 | : []; |
src/web/pages/app_templates.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** | |
| 2 | * App templates, for a forge admin: the repositories New app offers, with a | |
| 3 | * form to mark another or unmark one. Anyone else is told 403. | |
| 4 | */ | |
| 5 | ||
| 6 | import { MARK_TEMPLATE_ERRORS, markTemplate } from "../../actions/app_templates.ts"; | |
| 7 | import { DEFAULT_TEMPLATE, markedTemplates } from "../../core/app_templates.ts"; | |
| 8 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 9 | import type { Ctx } from "../../http/context.ts"; | |
| 10 | import { settingsLayout, signInFirst } from "../chrome.ts"; | |
| 11 | import { field, readForm } from "../form.ts"; | |
| 12 | import { html } from "../html.ts"; | |
| 13 | import { flashTo, page } from "../layout.ts"; | |
| 14 | import { btn, empty, field as formField, icon, row } from "../ui.ts"; | |
| 15 | import { accountSections } from "./origins.ts"; | |
| 16 | ||
| 17 | function denied(ctx: Ctx): Response | undefined { | |
| 18 | const user = ctx.auth.user; | |
| 19 | if (!user) return signInFirst(ctx); | |
| 20 | if (user.isAdmin && user.kind === "user") return undefined; | |
| 21 | return page( | |
| 22 | { | |
| 23 | ctx, | |
| 24 | title: "Forbidden", | |
| 25 | body: html`<div class="page-head"><h1>403</h1></div> | |
| 26 | ${empty({ | |
| 27 | icon: "lock", | |
| 28 | title: "Admins only", | |
| 29 | note: MARK_TEMPLATE_ERRORS.forbidden, | |
| 30 | action: btn("Go home", { href: "/", variant: "primary" }), | |
| 31 | })}`, | |
| 32 | }, | |
| 33 | 403, | |
| 34 | ); | |
| 35 | } | |
| 36 | ||
| 37 | // GET /settings/app-templates | |
| 38 | export function appTemplatesPage(ctx: Ctx): Response { | |
| 39 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 40 | const no = denied(ctx); | |
| 41 | if (no) return no; | |
| 42 | const rendering = performance.now(); | |
| 43 | const rows = markedTemplates(ctx.app.db).map((repo) => { | |
| 44 | const name = `${repo.ownerName}/${repo.name}`; | |
| 45 | return row({ | |
| 46 | lead: icon("rocket"), | |
| 47 | title: html`<a class="mono" href="/${name}">${name}</a>`, | |
| 48 | meta: html`${name === DEFAULT_TEMPLATE ? "Always offered, first." : repo.visibility === "private" ? "Private: offered only to people who can read it." : ""}`, | |
| 49 | trail: | |
| 50 | name === DEFAULT_TEMPLATE | |
| 51 | ? html`` | |
| 52 | : html`<form method="post" action="/settings/app-templates"> | |
| 53 | <input type="hidden" name="repo" value="${name}" /> | |
| 54 | <input type="hidden" name="marked" value="0" /> | |
| 55 | ${btn("Unmark", { size: "sm", label: `Unmark ${name}` })} | |
| 56 | </form>`, | |
| 57 | }); | |
| 58 | }); | |
| 59 | const body = html`<section class="settings-block"> | |
| 60 | <p class="muted">The repositories New app can copy.</p> | |
outdated
Principle 1: the h1 already says 'App templates'; this h2 repeats it. Drop the h2. |
||
| 61 | ${rows.length === 0 ? html`<p class="muted">No template is marked.</p>` : html`<ul class="list setting-list">${rows}</ul>`} | |
outdated
Principle 1: one confident sentence, not an explanation of the picker mechanics. Shorten to what the page is for. |
||
| 62 | <form method="post" action="/settings/app-templates"> | |
| 63 | <input type="hidden" name="marked" value="1" /> | |
| 64 | ${formField({ label: "Mark a repository", name: "repo", placeholder: "owner/name", mono: true, required: true })} | |
| 65 | ${btn("Mark as template", { variant: "primary" })} | |
| 66 | </form> | |
| 67 | </section>`; | |
| 68 | const response = page({ | |
| 69 | ctx, | |
| 70 | title: "App templates", | |
| 71 | body: html`<div class="page-head"><h1>App templates</h1></div> | |
| 72 | ${settingsLayout(accountSections("templates", { admin: true }), body, "Account settings")}`, | |
| 73 | }); | |
| 74 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 75 | return response; | |
| 76 | } | |
| 77 | ||
| 78 | // POST /settings/app-templates | |
| 79 | export async function appTemplatesSet(ctx: Ctx): Promise<Response> { | |
| 80 | const no = denied(ctx); | |
| 81 | if (no) return no; | |
| 82 | const form = await readForm(ctx, "admin"); | |
| 83 | if (form instanceof Response) return form; | |
| 84 | const marked = field(form, "marked") === "1"; | |
| 85 | const done = markTemplate(ctx.app, ctx.auth.user, field(form, "repo"), marked); | |
| 86 | return flashTo( | |
| 87 | ctx, | |
| 88 | "/settings/app-templates", | |
| 89 | done.ok | |
| 90 | ? { message: `${done.value.name} is ${marked ? "marked" : "unmarked"}.` } | |
| 91 | : { tone: "danger", message: MARK_TEMPLATE_ERRORS[done.error] }, | |
| 92 | ); | |
| 93 | } |
src/web/pages/apps.ts +12 −2
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 16 | 16 | import { repositoryLimitMessage } from "../../actions/repos.ts"; |
| 17 | 17 | import { appLimitWords } from "../../actions/app_limit.ts"; |
| 18 | import { offeredTemplates } from "../../core/app_templates.ts"; | |
| 18 | 19 | import { getUserByName } from "../../core/users.ts"; |
| 19 | 20 | import { |
| 20 | 21 | NEW_APP_ERRORS, |
| @@ -315,6 +316,8 @@ function newAppForm( | ||
| 315 | 316 | ); |
| 316 | 317 | const crew = agents.ok ? agents.value : []; |
| 317 | 318 | const domain = appsRuntime(ctx.app)?.options.domain; |
| 319 | // The marked templates this person can read; a picker only when there is a choice. | |
| 320 | const offered = timePhase(ctx.request, "templates", () => offeredTemplates(ctx.app.db, user)); | |
| 318 | 321 | |
| 319 | 322 | // An org's form stays the org's: carry the owner only when it is one the |
| 320 | 323 | // select offers, and only when it is not the viewer's own default. |
| @@ -330,7 +333,13 @@ function newAppForm( | ||
| 330 | 333 | ${html`<form method="post" action="/apps/new"> |
| 331 | 334 | ${values.template |
| 332 | 335 | ? formField({ label: "Copy of", name: "template", value: values.template, readonly: true, mono: true }) |
| 333 | : html``} | |
| 336 | : offered.length > 1 | |
| 337 | ? selectField({ | |
| 338 | label: "Start from", | |
| 339 | name: "template", | |
| 340 | options: offered.map((t) => ({ value: t, label: t })), | |
| 341 | }) | |
| 342 | : html``} | |
| 334 | 343 | ${formField({ |
| 335 | 344 | label: "What should it do?", |
| 336 | 345 | name: "description", |
| @@ -542,7 +551,8 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 542 | 551 | ), |
| 543 | 552 | scriptJs: appNameJs, |
| 544 | 553 | }, |
| 545 | 422, | |
| 554 | // A template the person cannot read answers as one that does not exist, as the API does. | |
| 555 | made.error === "no_such_template" ? 404 : 422, | |
| 546 | 556 | ); |
| 547 | 557 | } |
| 548 | 558 | const { repo, app: record, issue, plan } = made.value; |
src/web/pages/origins.ts +2
| @@ -114,6 +114,7 @@ export type AccountSection = | ||
| 114 | 114 | | "limits" |
| 115 | 115 | | "economics" |
| 116 | 116 | | "flags" |
| 117 | | "templates" | |
| 117 | 118 | | "features" |
| 118 | 119 | | "packs" |
| 119 | 120 | | "cloud"; |
| @@ -162,6 +163,7 @@ const SECTION_ROWS: readonly (readonly [ | ||
| 162 | 163 | ["This forge", "Economics", "/settings/economics", "activity", (c) => c === "economics", true], |
| 163 | 164 | ["This forge", "Flows", "/admin/flows", "activity", (c) => c === "adminflows", true], |
| 164 | 165 | ["This forge", "Flags", "/settings/flags", "activity", (c) => c === "flags", true], |
| 166 | ["This forge", "Templates", "/settings/app-templates", "rocket", (c) => c === "templates", true], | |
| 165 | 167 | ["This forge", "Features", "/settings/features", "rocket", (c) => c === "features", true], |
| 166 | 168 | ["This forge", "Packs", "/settings/packs", "rocket", (c) => c === "packs", true], |
| 167 | 169 | ["This forge", "Backups", "/settings/backups", "download", (c) => c === "backups", true], |
test/app_templates.test.ts added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | /** | |
| 2 | * App templates: an admin marks repositories, New app offers the marked ones | |
| 3 | * the caller can read (default first), and a name the caller cannot read is 404. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { setRole } from "../src/actions/roles.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { offeredTemplates } from "../src/core/app_templates.ts"; | |
| 13 | import { eventAudience } from "../src/core/event_audience.ts"; | |
| 14 | import { getRepo, listReposOwnedBy } from "../src/core/repos.ts"; | |
| 15 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | setDefaultTimeout(30_000); | |
| 22 | ||
| 23 | let dir: string; | |
| 24 | let app: App; | |
| 25 | let servers: Servers; | |
| 26 | let base: string; | |
| 27 | let adminToken: string; | |
| 28 | let takerToken: string; | |
| 29 | let takerCookie: string; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | dir = await mkdtemp(join(tmpdir(), "forge-apptpl-")); | |
| 33 | app = createApp({ dataDir: dir, port: 0 }); | |
| 34 | servers = await serve(app); | |
| 35 | base = servers.url; | |
| 36 | const mk = async (username: string, adminIfFirst = false) => { | |
| 37 | const made = await createUser(app.db, { | |
| 38 | origin: "seed", trusted: true, username, email: `${username}@x.test`, | |
| 39 | password: "a-good-passphrase", adminIfFirst, | |
| 40 | }); | |
| 41 | if (!made.ok) throw new Error(made.error); | |
| 42 | return made.value; | |
| 43 | }; | |
| 44 | const admin = await mk("giver", true); | |
| 45 | const trevato = await mk("trevato"); | |
| 46 | const taker = await mk("taker"); | |
| 47 | setRole(app, admin, taker.username, "member"); | |
| 48 | setRole(app, admin, trevato.username, "member"); | |
| 49 | const make = async (owner: typeof admin, name: string, visibility: "public" | "private") => { | |
| 50 | const repo = await createRepo(app, owner, { name, visibility, description: "" }); | |
| 51 | if (!repo.ok) throw new Error(repo.error); | |
| 52 | const done = await commitToBranch(app, owner, repo.value, { | |
| 53 | message: "t", | |
| 54 | files: [{ path: "README.md", content: `# ${name}\n` }], | |
| 55 | }); | |
| 56 | if (!done.ok) throw new Error(String(done.error)); | |
| 57 | }; | |
| 58 | await make(trevato, "app-template", "public"); | |
| 59 | await make(admin, "kit", "public"); | |
| 60 | await make(admin, "secret", "private"); | |
| 61 | adminToken = tokenFor(app, "giver", "admin"); | |
| 62 | takerToken = tokenFor(app, "taker", "admin"); | |
| 63 | takerCookie = ( | |
| 64 | ( | |
| 65 | await fetch(`${base}/signin`, { | |
| 66 | method: "POST", | |
| 67 | body: new URLSearchParams({ username: "taker", password: "a-good-passphrase" }), | |
| 68 | redirect: "manual", | |
| 69 | }) | |
| 70 | ).headers.get("set-cookie") ?? "" | |
| 71 | ).split(";")[0]!; | |
| 72 | }); | |
| 73 | afterAll(async () => { | |
| 74 | servers.stop(); | |
| 75 | app.close(); | |
| 76 | await rm(dir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | const mark = (token: string, repo: string, marked: boolean) => | |
| 80 | fetch(`${base}/api/v1/admin/app-templates`, { | |
| 81 | method: "POST", | |
| 82 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 83 | body: JSON.stringify({ repo, marked }), | |
| 84 | }); | |
| 85 | const listed = async (token: string): Promise<string[]> => | |
| 86 | ((await (await fetch(`${base}/api/v1/app-templates`, { headers: { authorization: `Bearer ${token}` } })).json()) as { templates: string[] }).templates; | |
| 87 | const page = async (): Promise<string> => (await fetch(`${base}/apps/new`, { headers: { cookie: takerCookie } })).text(); | |
| 88 | ||
| 89 | test("with only the default marked, it is listed and the page shows no picker", async () => { | |
| 90 | expect(await listed(takerToken)).toEqual(["trevato/app-template"]); | |
| 91 | expect(await page()).not.toContain('name="template"'); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a non-admin cannot mark (403) and nothing changes", async () => { | |
| 95 | const res = await mark(takerToken, "giver/kit", true); | |
| 96 | expect(res.status).toBe(403); | |
| 97 | expect(offeredTemplates(app.db, getUserByName(app.db, "taker"))).toEqual(["trevato/app-template"]); | |
| 98 | }); | |
| 99 | ||
| 100 | test("an admin marks; the default stays first and the page shows a picker", async () => { | |
| 101 | expect((await mark(adminToken, "giver/kit", true)).status).toBe(200); | |
| 102 | expect((await mark(adminToken, "giver/kit", true)).status).toBe(200); // idempotent | |
| 103 | expect(await listed(takerToken)).toEqual(["trevato/app-template", "giver/kit"]); | |
| 104 | const html = await page(); | |
| 105 | expect(html).toContain('name="template"'); | |
| 106 | expect(html.indexOf("trevato/app-template")).toBeLessThan(html.indexOf("giver/kit")); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a private marked template is listed only to those who can read it", async () => { | |
| 110 | expect((await mark(adminToken, "giver/secret", true)).status).toBe(200); | |
| 111 | expect(await listed(adminToken)).toEqual(["trevato/app-template", "giver/kit", "giver/secret"]); | |
| 112 | expect(await listed(takerToken)).toEqual(["trevato/app-template", "giver/kit"]); | |
| 113 | expect(await page()).not.toContain("giver/secret"); | |
| 114 | }); | |
| 115 | ||
| 116 | test("choosing an unreadable template by name makes nothing and is refused", async () => { | |
| 117 | const res = await fetch(`${base}/api/v1/apps`, { | |
| 118 | method: "POST", | |
| 119 | headers: { authorization: `Bearer ${takerToken}`, "content-type": "application/json" }, | |
| 120 | body: JSON.stringify({ name: "sneaky", description: "x", template: "giver/secret" }), | |
| 121 | }); | |
| 122 | expect(res.status).toBe(404); | |
| 123 | expect(getRepo(app.db, "taker", "sneaky")).toBeUndefined(); | |
| 124 | }); | |
| 125 | ||
| 126 | test("unmarking removes it; the default cannot be unmarked; an unknown name is 404", async () => { | |
| 127 | expect((await mark(adminToken, "giver/secret", false)).status).toBe(200); | |
| 128 | expect(await listed(adminToken)).toEqual(["trevato/app-template", "giver/kit"]); | |
| 129 | expect((await mark(adminToken, "trevato/app-template", false)).status).toBe(422); | |
| 130 | expect((await mark(adminToken, "giver/none", true)).status).toBe(404); | |
| 131 | expect((await mark(adminToken, "bad name", true)).status).toBe(404); | |
| 132 | }); | |
| 133 | ||
| 134 | test("the settings page is admin-only", async () => { | |
| 135 | const forTaker = await fetch(`${base}/settings/app-templates`, { headers: { cookie: takerCookie } }); | |
| 136 | expect(forTaker.status).toBe(403); | |
| 137 | const asAdmin = await fetch(`${base}/settings/app-templates`, { headers: { authorization: `Bearer ${adminToken}` } }); | |
| 138 | expect(asAdmin.status).toBe(200); | |
| 139 | expect(await asAdmin.text()).toContain("giver/kit"); | |
| 140 | }); | |
| 141 | ||
| 142 | test("the web New app form refuses an unreadable named template with 404 and makes nothing", async () => { | |
| 143 | expect((await mark(adminToken, "giver/secret", true)).status).toBe(200); | |
| 144 | const res = await fetch(`${base}/apps/new`, { | |
| 145 | method: "POST", | |
| 146 | headers: { cookie: takerCookie, origin: base }, | |
| 147 | body: new URLSearchParams({ description: "a plant waterer", template: "giver/secret" }), | |
| 148 | redirect: "manual", | |
| 149 | }); | |
| 150 | expect(res.status).toBe(404); | |
| 151 | expect(await res.text()).not.toContain("giver/secret</"); | |
| 152 | expect(listReposOwnedBy(app.db, getUserByName(app.db, "taker")!.id)).toEqual([]); | |
| 153 | }); | |
| 154 | ||
| 155 | test("marking and unmarking is news for admins alone, so a private name is not broadcast", () => { | |
| 156 | expect(eventAudience("app_template.marked")).toBe("admins"); | |
| 157 | expect(eventAudience("app_template.unmarked")).toBe("admins"); | |
| 158 | }); |
test/palette-roster.test.ts +1
| @@ -142,6 +142,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 142 | 142 | ["invites", "Invite links", "make a single-use link, see which are open, revoke one"], |
| 143 | 143 | ["bulk", "Bulk actions", "your last bulk actions; u undoes one inside its day"], |
| 144 | 144 | ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"], |
| 145 | ["app-templates", "App templates", "the repositories New app offers; mark or unmark one"], | |
| 145 | 146 | ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"], |
| 146 | 147 | ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"], |
| 147 | 148 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
test/tui-app-templates.test.ts added +175
| @@ -0,0 +1,175 @@ | ||
| 1 | /** | |
| 2 | * App templates in the terminal: an admin marks and unmarks from a screen | |
| 3 | * (nobody else is offered it), and New app asks which template when more than | |
| 4 | * one is offered, goes straight to the form with one, and refuses a name that | |
| 5 | * is not offered or not readable. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { setRole } from "../src/actions/roles.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { offeredTemplates } from "../src/core/app_templates.ts"; | |
| 15 | import { listReposOwnedBy } from "../src/core/repos.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 18 | import { blank } from "../src/tui/screens/common.ts"; | |
| 19 | import { appTemplatesScreen } from "../src/tui/screens/app_templates.ts"; | |
| 20 | import { newAppScreen } from "../src/tui/screens/newapp.ts"; | |
| 21 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | import { settleApp } from "./helpers/tui.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | setDefaultTimeout(30_000); | |
| 27 | ||
| 28 | let dir: string; | |
| 29 | let app: App; | |
| 30 | let admin: User; | |
| 31 | let taker: User; | |
| 32 | ||
| 33 | const identity = (user?: User) => ({ | |
| 34 | sshUsername: user?.username ?? "git", | |
| 35 | remoteAddress: "127.0.0.1", | |
| 36 | fingerprint: "SHA256:test", | |
| 37 | user, | |
| 38 | }); | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | dir = await mkdtemp(join(tmpdir(), "forge-tui-apptpl-")); | |
| 42 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 43 | const mk = async (username: string, adminIfFirst = false) => { | |
| 44 | const made = await createUser(app.db, { | |
| 45 | origin: "seed", trusted: true, username, email: `${username}@x.test`, | |
| 46 | password: "a-good-passphrase", adminIfFirst, | |
| 47 | }); | |
| 48 | if (!made.ok) throw new Error(made.error); | |
| 49 | return made.value; | |
| 50 | }; | |
| 51 | admin = await mk("giver", true); | |
| 52 | taker = await mk("taker"); | |
| 53 | const trevato = await mk("trevato"); | |
| 54 | setRole(app, admin, taker.username, "member"); | |
| 55 | setRole(app, admin, trevato.username, "member"); | |
| 56 | const make = async (owner: User, name: string, visibility: "public" | "private") => { | |
| 57 | const repo = await createRepo(app, owner, { name, visibility, description: "" }); | |
| 58 | if (!repo.ok) throw new Error(repo.error); | |
| 59 | const done = await commitToBranch(app, owner, repo.value, { | |
| 60 | message: "t", | |
| 61 | files: [{ path: "README.md", content: `# ${name}\n` }], | |
| 62 | }); | |
| 63 | if (!done.ok) throw new Error(String(done.error)); | |
| 64 | }; | |
| 65 | await make(trevato, "app-template", "public"); | |
| 66 | await make(admin, "kit", "public"); | |
| 67 | await make(admin, "secret", "private"); | |
| 68 | }); | |
| 69 | afterAll(async () => { | |
| 70 | app.db.close(); | |
| 71 | await rm(dir, { recursive: true, force: true }); | |
| 72 | }); | |
| 73 | ||
| 74 | async function until(done: () => boolean): Promise<void> { | |
| 75 | const deadline = Date.now() + 5000; | |
| 76 | while (!done() && Date.now() < deadline) await Bun.sleep(20); | |
| 77 | if (!done()) throw new Error("never came"); | |
| 78 | } | |
| 79 | ||
| 80 | async function withScreen( | |
| 81 | user: User, | |
| 82 | make: (session: Session, ctx: Parameters<typeof newAppScreen>[1]) => ReturnType<typeof newAppScreen>, | |
| 83 | body: (h: { text: () => string; feed: (s: string) => Promise<void> }) => Promise<void>, | |
| 84 | ) { | |
| 85 | const terminal = headlessTerminal(120, 30); | |
| 86 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal, exitHooks: false }); | |
| 87 | tui.start(); | |
| 88 | tui.ctx.push(make(session, tui.ctx)); | |
| 89 | try { | |
| 90 | await settleApp(tui); | |
| 91 | await body({ | |
| 92 | text: () => tui.screen.toText(), | |
| 93 | feed: async (s) => { | |
| 94 | terminal.feed(s); | |
| 95 | await settleApp(tui); | |
| 96 | }, | |
| 97 | }); | |
| 98 | } finally { | |
| 99 | tui.stop(); | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | const markScreen = (s: Session, c: Parameters<typeof newAppScreen>[1]) => appTemplatesScreen(s, c); | |
| 104 | const newApp = (s: Session, c: Parameters<typeof newAppScreen>[1]) => newAppScreen(s, c, blank("New app")); | |
| 105 | ||
| 106 | describe("the app templates screen", () => { | |
| 107 | test("only an admin is offered it", () => { | |
| 108 | const ids = (u?: User) => forgeCommands(new Session(app, identity(u)))({} as never).map((c) => c.id); | |
| 109 | expect(ids(admin)).toContain("app-templates"); | |
| 110 | expect(ids(taker)).not.toContain("app-templates"); | |
| 111 | expect(ids({ ...admin, kind: "agent" })).not.toContain("app-templates"); | |
| 112 | expect(ids()).not.toContain("app-templates"); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a non-admin who reaches it sees the refusal and marks nothing", () => | |
| 116 | withScreen(taker, markScreen, async ({ text, feed }) => { | |
| 117 | expect(text()).toContain("Only a site admin marks app templates."); | |
| 118 | await feed("m"); | |
| 119 | await feed("giver/kit\r"); | |
| 120 | expect(offeredTemplates(app.db, admin)).toEqual(["trevato/app-template"]); | |
| 121 | })); | |
| 122 | ||
| 123 | test("m marks, a missing name is refused in the action's words, u asks first then unmarks", () => | |
| 124 | withScreen(admin, markScreen, async ({ text, feed }) => { | |
| 125 | await feed("m"); | |
| 126 | await feed("giver/none\r"); | |
| 127 | expect(text()).toContain("No repository by that name."); | |
| 128 | await feed("\x1b"); | |
| 129 | await Bun.sleep(100); | |
| 130 | await feed("m"); | |
| 131 | await feed("giver/kit\r"); | |
| 132 | expect(offeredTemplates(app.db, admin)).toEqual(["trevato/app-template", "giver/kit"]); | |
| 133 | await feed("j"); | |
| 134 | await feed("u"); | |
| 135 | await feed("\x1b"); // esc keeps it | |
| 136 | await Bun.sleep(100); | |
| 137 | expect(offeredTemplates(app.db, admin)).toEqual(["trevato/app-template", "giver/kit"]); | |
| 138 | await feed("u"); | |
| 139 | await feed("\x1b[C\r"); // right to Unmark, enter | |
| 140 | expect(offeredTemplates(app.db, admin)).toEqual(["trevato/app-template"]); | |
| 141 | })); | |
| 142 | }); | |
| 143 | ||
| 144 | describe("the New app picker", () => { | |
| 145 | test("one offered goes straight to the form", () => | |
| 146 | withScreen(taker, newApp, async ({ text }) => { | |
| 147 | expect(text()).toContain("What should it do?"); | |
| 148 | expect(text()).not.toContain("Start from which template?"); | |
| 149 | })); | |
| 150 | ||
| 151 | test("more than one offered asks which first, refuses a name not offered, and makes nothing", async () => { | |
| 152 | const { markTemplate } = await import("../src/actions/app_templates.ts"); | |
| 153 | expect(markTemplate(app, admin, "giver/kit", true).ok).toBe(true); | |
| 154 | expect(markTemplate(app, admin, "giver/secret", true).ok).toBe(true); | |
| 155 | await withScreen(taker, newApp, async ({ text, feed }) => { | |
| 156 | expect(text()).toContain("Start from which template?"); | |
| 157 | expect(text()).toContain("trevato/app-template"); | |
| 158 | expect(text()).not.toContain("giver/secret"); | |
| 159 | for (let i = 0; i < "trevato/app-template".length; i++) await feed("\x7f"); | |
| 160 | await feed("giver/secret\r"); | |
| 161 | expect(text()).toContain("Pick one of: trevato/app-template, giver/kit."); | |
| 162 | expect(listReposOwnedBy(app.db, taker.id)).toEqual([]); | |
| 163 | for (let i = 0; i < "giver/secret".length; i++) await feed("\x7f"); | |
| 164 | await feed("giver/kit\r"); | |
| 165 | expect(text()).toContain("New app from giver/kit"); | |
| 166 | }); | |
| 167 | }); | |
| 168 | ||
| 169 | test("a named unreadable template is refused as no such template and makes nothing", () => | |
| 170 | withScreen(taker, (s, c) => newAppScreen(s, c, blank("New app"), "giver/secret"), async ({ text, feed }) => { | |
| 171 | await feed("a plant waterer\r"); | |
| 172 | await until(() => text().includes("No template by that name that you can read.")); | |
| 173 | expect(listReposOwnedBy(app.db, taker.id)).toEqual([]); | |
| 174 | })); | |
| 175 | }); |
Comment says everyone else is told 404; the code returns forbidden (403). Correct it.