Public: anyone can read this; the crew can change it.
The app template is a repository: new apps start from trevato/app-template, and scaffold.ts's strings go #11662
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10305-the-app-template-is-a-repository-new-app → main 13 commits fork
27 files
changed +690
−6240
Dockerfile +2
| @@ -25,6 +25,8 @@ COPY docs/DESIGN.md ./docs/DESIGN.md | ||
| 25 | 25 | COPY plugins/opencode ./plugins/opencode |
| 26 | 26 | # The Claude Code command files the marketplace manifest lists (same route file). |
| 27 | 27 | COPY plugins/claude-code/commands ./plugins/claude-code/commands |
| 28 | # The app template every forge can make apps from, taken from trevato/app-template (src/apps/template.ts). | |
| 29 | COPY templates ./templates | |
| 28 | 30 | # src/ alone: nothing under it imports scripts/ or test/ (test/src-imports.test.ts), |
| 29 | 31 | # and the boot proof below fails the build if that ever stops being so (#2652). |
| 30 | 32 |
docs/APPS.md +1 −1
| @@ -992,7 +992,7 @@ generous because the first boot migrates. | ||
| 992 | 992 | ## What a new app starts as |
| 993 | 993 | |
| 994 | 994 | **New app** writes a small, complete app into the repository |
| 995 | (`src/apps/scaffold.ts`): notes behind a page, a JSON API, an MCP server | |
| 995 | (`src/apps/template.ts`): notes behind a page, a JSON API, an MCP server | |
| 996 | 996 | and a terminal, all over one set of actions, with no dependencies. What |
| 997 | 997 | the forge already does for every app (identity, the grant, preview data, |
| 998 | 998 | health polling, the terminal, the swap, sleep, the contract's wall) is not |
docs/SPAWN.md +1 −1
| @@ -78,7 +78,7 @@ A spawn may also name a `size`: `small`, `medium` or `large` (`src/spawn/sizes.t | ||
| 78 | 78 | |
| 79 | 79 | ## Choosing a template (#7533) |
| 80 | 80 | |
| 81 | A template is a named starting point for a new forge's apps, listed in `src/spawn/templates.ts`: each has an `id`, a `title`, one sentence `says`, and a `source` — `builtin` (its files are this forge's code) or `repo` (read from `repo`, an `owner/name` on this forge; none yet, #7536). Built in are `blank`, the default and what a spawn has always made (the forked apps and an optional `fresh` one), and `starter`, the app `/apps/new` starts from (`scaffoldFiles`, `src/apps/scaffold.ts`). | |
| 81 | A template is a named starting point for a new forge's apps, listed in `src/spawn/templates.ts`: each has an `id`, a `title`, one sentence `says`, and a `source` — `builtin` (its files are this forge's code) or `repo` (read from `repo`, an `owner/name` on this forge; none yet, #7536). Built in are `blank`, the default and what a spawn has always made (the forked apps and an optional `fresh` one), and `starter`, the app `/apps/new` starts from (`scaffoldFiles`, `src/apps/template.ts`). | |
| 82 | 82 | |
| 83 | 83 | `POST /api/v1/forges` and the MCP `spawn` take an optional `template` id. An unknown id is a 422 on `template` naming the valid ids. Absent or `blank`, the plan is the one it always was; any other id is named in the plan, the ledger line, `parent.json` and the child's row in `GET /api/v1/forges` (absent there means `blank`). Because it is in the plan, a retry with another template is refused as a different child. A `starter` child begins with the template's files (#7535, `src/spawn/template_birth.ts`): one commit, no history and no upstream, in a new private repo of the child's organisation named for the `fresh` sentence (which becomes its description; no second repo) or `app`. `parent.json` records `template: {id}`. Only tracked files travel, nothing from the "never copied" list; a retry finds the repo and leaves it. |
| 84 | 84 |
docs/features/app-template.md +11
| @@ -20,3 +20,14 @@ A marked repository whose top-level folders hold both `TEMPLATE.md` and `templat | ||
| 20 | 20 | - **Literals:** `template.json` is `{ "files": ["README.md"], "literals": ["{{name}}", "{{description}}"] }`. Each named file must be in the folder (no `..`, no leading `/`) and each literal one of those two; anything else is `template_invalid` and nothing is made. |
| 21 | 21 | - **Never run.** A script in a folder (`new.mjs`) is copied as a file and not executed. |
| 22 | 22 | - The terminal's New app prompt lists the folders too (`openNewApp` loads the choices before the screen opens). |
| 23 | ||
| 24 | ## The default app is a repository | |
| 25 | ||
| 26 | The app New app starts from when no template is named is `trevato/app-template` (`apps.template` in `forge_setting` names another), copied at its default branch's tip: one commit, no history (#10305). The strings that used to make it (`src/apps/scaffold.ts`) are gone. | |
| 27 | ||
| 28 | - **Name and description.** The template's `template.json` is `{ "name": "app-template", "description": "A new app on the forge.", "files": [...] }`: the literals the forge replaces, and the only files it replaces them in (`.json` files get the JSON-escaped form). Nothing else is touched, so the template runs and passes its own tests as it stands. `template.json` is not copied. An empty description keeps the template's line. (This is the root template's form; a folder of a template repository uses the `{{name}}` form above.) | |
| 29 | - **A copy in every forge.** `templates/app-template.json` is the template's files, carried in the image (`Dockerfile` copies `templates/`). A forge without the repository (a child, a pack forge, a fresh install), or whose copy has no readable `template.json`, uses it; so does `src/spawn/template_birth.ts` (`bakedFiles`). Refresh it with `bun scripts/take-template.ts <checkout>`, which reads bytes and refuses any file that is not UTF-8 text (the bundle is JSON), and marks the bundle `pinned: true` with HEAD as `commit`. A test fails when a pinned bundle's `commit` is not a 40-hex id; the file in the tree says `pinned: false` until it is taken. **Not done:** the file in the tree was generated from the removed strings, so its `commit` is the forge commit they were written at, not a commit of trevato/app-template, and the release build does not yet run `take-template.ts` at a pinned commit; until it does the copy and the repository can differ. | |
| 30 | - **`apps.template` is operator-only on purpose.** It is a `forge_setting` key with no page or terminal control: which repository every new app copies is a forge-wide choice, set by whoever runs the forge, not by an app owner. A user picks another template per app with `template:` (see above). | |
| 31 | - **Two `template.json` forms, pinned by a test.** The root template names its literals (`name`, `description`, `files`); a folder of a repository of templates uses `files` and `literals`. Each parser rejects the other's form. Both read paths with no dot segments, no `.git`, no absolute path, and the tip copy has the same caps as the rest (500 files, 512 KiB each, 4 MiB; over any falls back to the image's copy). | |
| 32 | - **Checked both ways.** In the template's repository a pull runs its own `bun test`, then `bun scripts/birth-check.ts <checkout>` from this repository, which fills the tip as New app does, boots the result on a port the kernel chose (retried if taken) and waits on `/healthz` and `/`. Wiring that into the template repository's `forge.json` is done in that repository, not here. | |
| 33 | - **Code and tests.** `src/apps/template.ts`; `test/app-template.test.ts`, `test/template-scripts.test.ts` (the two scripts). The fresh-app suites (`test/scaffold*.test.ts`, `test/apps-new.test.ts`) boot the filled copy. |
scripts/birth-check.ts added +69
| @@ -0,0 +1,69 @@ | ||
| 1 | /** | |
| 2 | * `bun scripts/birth-check.ts <checkout>`: the second half of a template | |
| 3 | * pull's check (#10305). Births an app from the checkout's tracked files | |
| 4 | * exactly as New app does (`fillTemplate`), writes it to a scratch | |
| 5 | * directory, boots it on a free port with its own data directory and waits | |
| 6 | * for `/healthz`. The template's own `bun test` is the first half and runs | |
| 7 | * in the checkout itself. Exits 0 when the born app answers, 1 when not. | |
| 8 | */ | |
| 9 | ||
| 10 | import { mkdtemp, mkdir, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { dirname, join } from "node:path"; | |
| 13 | import { fillTemplate } from "../src/apps/template.ts"; | |
| 14 | ||
| 15 | const [dir] = process.argv.slice(2); | |
| 16 | if (dir === undefined) { | |
| 17 | console.error("usage: bun scripts/birth-check.ts <checkout>"); | |
| 18 | process.exit(2); | |
| 19 | } | |
| 20 | const listed = Bun.spawnSync(["git", "-C", dir, "ls-files", "-z"], { stdout: "pipe", stderr: "pipe" }); | |
| 21 | if (listed.exitCode !== 0) { | |
| 22 | console.error(`birth-check: not a git checkout: ${listed.stderr.toString().trim()}`); | |
| 23 | process.exit(1); | |
| 24 | } | |
| 25 | const paths = listed.stdout.toString().split("\0").filter((p) => p !== ""); | |
| 26 | const files = await Promise.all(paths.map(async (path) => ({ path, content: new Uint8Array(await Bun.file(join(dir, path)).arrayBuffer()) }))); | |
| 27 | const born = fillTemplate({ files, from: "checkout", commit: "" }, { name: "born-from-template", description: "Born in the template check." }); | |
| 28 | if (born === undefined) { | |
| 29 | console.error("birth-check: template.json is missing or unreadable"); | |
| 30 | process.exit(1); | |
| 31 | } | |
| 32 | ||
| 33 | const work = await mkdtemp(join(process.env["TMPDIR"] ?? tmpdir(), "birth-check-")); | |
| 34 | let child: ReturnType<typeof Bun.spawn> | undefined; | |
| 35 | try { | |
| 36 | for (const f of born) { | |
| 37 | const to = join(work, "app", f.path); | |
| 38 | await mkdir(dirname(to), { recursive: true }); | |
| 39 | await Bun.write(to, f.content ?? ""); | |
| 40 | } | |
| 41 | // Port 0 asks the kernel for a free one; the app is told it only after the | |
| 42 | // probe socket is closed, so a port taken in between is retried, not fatal. | |
| 43 | let up = false; | |
| 44 | let port = 0; | |
| 45 | for (let attempt = 0; attempt < 3 && !up; attempt++) { | |
| 46 | const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); | |
| 47 | port = probe.port; | |
| 48 | probe.stop(true); | |
| 49 | child = Bun.spawn(["bun", "run", "src/server.ts"], { | |
| 50 | cwd: join(work, "app"), | |
| 51 | env: { PATH: process.env["PATH"] ?? "", PORT: String(port), FORGE_APP_DATA: join(work, "data"), FORGE_APP_ENV: "preview" }, | |
| 52 | stdout: "ignore", | |
| 53 | stderr: "inherit", | |
| 54 | }); | |
| 55 | for (let i = 0; i < 100 && !up && child.exitCode === null; i++) { | |
| 56 | await Bun.sleep(100); | |
| 57 | up = await fetch(`http://127.0.0.1:${port}/healthz`).then((r) => r.ok, () => false); | |
| 58 | } | |
| 59 | if (!up) child.kill(); | |
| 60 | } | |
| 61 | const home = up ? await fetch(`http://127.0.0.1:${port}/`).then((r) => r.text(), () => "") : ""; | |
| 62 | if (!up || !home.includes("born-from-template")) { | |
| 63 | console.error(`birth-check: the born app ${up ? "answers / without its name" : "did not answer /healthz"}`); | |
| 64 | process.exitCode = 1; | |
| 65 | } else console.log(`birth-check: born from ${paths.length} files, booted, / answers`); | |
| 66 | } finally { | |
| 67 | child?.kill(); | |
| 68 | await rm(work, { recursive: true, force: true }); | |
| 69 | } |
scripts/take-template.ts added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | /** | |
| 2 | * `bun scripts/take-template.ts <checkout> [owner/name]`: write | |
| 3 | * `templates/app-template.json`, the copy of the app template every forge | |
| 4 | * carries in its image (#10305), from a checkout of the template repository. | |
| 5 | * The tracked files at HEAD are taken as they are and HEAD is recorded as | |
| 6 | * `commit` in the bundle. It is meant to run on a checkout of | |
| 7 | * trevato/app-template at the commit a release pins (the release build does | |
| 8 | * not do so yet); a checkout without a readable `template.json`, or with a | |
| 9 | * file that is not UTF-8 text, is refused. `pinned: true` marks a bundle | |
| 10 | * taken this way; the file in the tree says `pinned: false` until one is. | |
| 11 | */ | |
| 12 | ||
| 13 | import { parseTemplateSpec } from "../src/apps/template.ts"; | |
| 14 | ||
| 15 | const [dir, repo = "trevato/app-template"] = process.argv.slice(2); | |
| 16 | // FORGE_TEMPLATE_OUT: where to write (tests); default the tree's bundle. | |
| 17 | const out = process.env["FORGE_TEMPLATE_OUT"] ?? `${import.meta.dir}/../templates/app-template.json`; | |
| 18 | if (dir === undefined) { | |
| 19 | console.error("usage: bun scripts/take-template.ts <checkout> [owner/name]"); | |
| 20 | process.exit(2); | |
| 21 | } | |
| 22 | const git = (...args: string[]): string => { | |
| 23 | const r = Bun.spawnSync(["git", "-C", dir, ...args], { stdout: "pipe", stderr: "pipe" }); | |
| 24 | if (r.exitCode !== 0) { | |
| 25 | console.error(`take-template: git ${args[0]} failed: ${r.stderr.toString().trim()}`); | |
| 26 | process.exit(1); | |
| 27 | } | |
| 28 | return r.stdout.toString(); | |
| 29 | }; | |
| 30 | const commit = git("rev-parse", "HEAD").trim(); | |
| 31 | const paths = git("ls-files", "-z").split("\0").filter((p) => p !== "").sort(); | |
| 32 | // The bundle is JSON text, so a file that is not UTF-8 text is refused, not | |
| 33 | // mangled: read the bytes and decode them strictly. | |
| 34 | const files: Record<string, string> = {}; | |
| 35 | const decoder = new TextDecoder("utf-8", { fatal: true }); | |
| 36 | for (const p of paths) { | |
| 37 | const bytes = new Uint8Array(await Bun.file(`${dir}/${p}`).arrayBuffer()); | |
| 38 | try { | |
| 39 | const body = decoder.decode(bytes); | |
| 40 | if (body.includes("\u0000")) throw new Error("NUL byte"); | |
| 41 | files[p] = body; | |
| 42 | } catch { | |
| 43 | console.error(`take-template: ${p} is not UTF-8 text; the bundle holds text files only`); | |
| 44 | process.exit(1); | |
| 45 | } | |
| 46 | } | |
| 47 | if (files["template.json"] === undefined || parseTemplateSpec(files["template.json"]) === undefined) { | |
| 48 | console.error("take-template: the checkout has no readable template.json"); | |
| 49 | process.exit(1); | |
| 50 | } | |
| 51 | await Bun.write( | |
| 52 | out, | |
| 53 | `${JSON.stringify({ repo, commit, pinned: true, files }, null, 1)}\n`, | |
| 54 | ); | |
| 55 | console.log(`took ${paths.length} files of ${repo} at ${commit}`); |
src/actions/apps.ts +3 −3
| @@ -23,7 +23,7 @@ import { appVitals, type AppVitals } from "../apps/vitals.ts"; | ||
| 23 | 23 | import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts"; |
| 24 | 24 | import { grantUrl, originFor, sideForViewer, sideReader } from "../apps/proxy.ts"; |
| 25 | 25 | import { plainLine } from "../apps/side.ts"; |
| 26 | import { scaffoldFiles } from "../apps/scaffold.ts"; | |
| 26 | import { scaffoldFiles } from "../apps/template.ts"; | |
| 27 | 27 | import type { FileWrite } from "../git/write.ts"; |
| 28 | 28 | import { TEMPLATE_ERRORS, templateFiles, type TemplateError } from "../apps/template_tip.ts"; |
| 29 | 29 | import { |
| @@ -364,10 +364,10 @@ export async function newApp( | ||
| 364 | 364 | message: `Start ${input.name}`, |
| 365 | 365 | files: |
| 366 | 366 | starter ?? |
| 367 | scaffoldFiles({ | |
| 367 | (await scaffoldFiles(app, { | |
| 368 | 368 | name: input.name, |
| 369 | 369 | description: input.description ?? "", |
| 370 | }), | |
| 370 | })), | |
| 371 | 371 | }); |
| 372 | 372 | lap("scaffold"); |
| 373 | 373 | if (!committed.ok) { |
src/apps/scaffold.ts deleted −6214
Diff not shown — 6,214 changed lines (limit 5,000). −6214
View filesrc/apps/template.ts added +192
| @@ -0,0 +1,192 @@ | ||
| 1 | /** | |
| 2 | * The app a new app starts as is a repository (#10305): `apps.template` | |
| 3 | * names it (default `trevato/app-template`), and New app copies its tracked | |
| 4 | * files at the default branch's tip — one commit, no history. Where the | |
| 5 | * forge has no such repository (a child forge, a pack forge, a fresh | |
| 6 | * install) the copy baked into the image is used: `templates/app-template.json`, | |
| 7 | * taken from the repository by `scripts/take-template.ts`. | |
| 8 | * | |
| 9 | * `template.json` in the template says which files carry the app's name and | |
| 10 | * description and the literals standing in for them; the forge replaces | |
| 11 | * those and leaves the rest byte for byte, so the template stays runnable | |
| 12 | * and its own tests pass as they are. `template.json` itself is not copied. | |
| 13 | */ | |
| 14 | ||
| 15 | import { readFileSync } from "node:fs"; | |
| 16 | import type { App } from "../app.ts"; | |
| 17 | import { DEFAULT_TEMPLATE } from "../core/app_templates.ts"; | |
| 18 | import { getRepo } from "../core/repos.ts"; | |
| 19 | import { listPaths, readerFor } from "../git/read.ts"; | |
| 20 | import { repoDir } from "../git/repos.ts"; | |
| 21 | import type { FileWrite } from "../git/write.ts"; | |
| 22 | import { TEMPLATE_MAX_BYTES, TEMPLATE_MAX_FILE_BYTES, TEMPLATE_MAX_FILES } from "./template_tip.ts"; | |
| 23 | ||
| 24 | export const TEMPLATE_SETTING = "apps.template"; | |
| 25 | ||
| 26 | // The same caps as every other template copy (template_tip.ts). | |
| 27 | const FILES_MAX = TEMPLATE_MAX_FILES; | |
| 28 | const FILE_BYTES_MAX = TEMPLATE_MAX_FILE_BYTES; | |
| 29 | const TOTAL_BYTES_MAX = TEMPLATE_MAX_BYTES; | |
| 30 | const SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/; | |
| 31 | ||
| 32 | export interface ScaffoldInput { | |
| 33 | /** The repository name; also the title on the page. */ | |
| 34 | readonly name: string; | |
| 35 | /** What the app should become. Shown on the page until it does. */ | |
| 36 | readonly description: string; | |
| 37 | } | |
| 38 | ||
| 39 | /** What `template.json` declares. */ | |
| 40 | export interface TemplateSpec { | |
| 41 | /** The literal standing in for the app's name. */ | |
| 42 | readonly name: string; | |
| 43 | /** The literal standing in for its description (also the default one). */ | |
| 44 | readonly description: string; | |
| 45 | /** The files that carry those literals. */ | |
| 46 | readonly files: readonly string[]; | |
| 47 | } | |
| 48 | ||
| 49 | /** A template as copied: its files, with `template.json` among them. */ | |
| 50 | export interface TemplateCopy { | |
| 51 | readonly files: readonly FileWrite[]; | |
| 52 | /** Where it came from: `owner/name`, and the tip or pinned commit. */ | |
| 53 | readonly from: string; | |
| 54 | readonly commit: string; | |
| 55 | } | |
| 56 | ||
| 57 | export const isTemplateSlug = (s: string): boolean => SLUG.test(s); | |
| 58 | ||
| 59 | /** A safe relative path: no dot segments, no `.git`, no absolute or odd bytes. */ | |
| 60 | function safePath(path: string): boolean { | |
| 61 | if (path === "" || path.length > 300 || path.startsWith("/") || /[\x00-\x1f\x7f\\]/.test(path)) return false; | |
| 62 | return path.split("/").every((p) => p !== "" && p !== "." && p !== ".." && p.toLowerCase() !== ".git"); | |
| 63 | } | |
| 64 | ||
| 65 | /** Parse `template.json`; undefined when it is not what the forge reads. */ | |
| 66 | export function parseTemplateSpec(text: string): TemplateSpec | undefined { | |
| 67 | let raw: unknown; | |
| 68 | try { | |
| 69 | raw = JSON.parse(text); | |
| 70 | } catch { | |
| 71 | return undefined; | |
| 72 | } | |
| 73 | if (typeof raw !== "object" || raw === null) return undefined; | |
| 74 | const { name, description, files } = raw as Record<string, unknown>; | |
| 75 | if (typeof name !== "string" || name === "" || name.length > 100) return undefined; | |
| 76 | if (typeof description !== "string" || description === "" || description.length > 500) return undefined; | |
| 77 | if (!Array.isArray(files) || files.length > 50) return undefined; | |
| 78 | if (!files.every((f) => typeof f === "string" && safePath(f))) return undefined; | |
| 79 | return { name, description, files: files as string[] }; | |
| 80 | } | |
| 81 | ||
| 82 | const text = (f: FileWrite): string => | |
| 83 | typeof f.content === "string" ? f.content : new TextDecoder().decode(f.content ?? new Uint8Array()); | |
| 84 | ||
| 85 | /** A literal as it sits inside a JSON string. */ | |
| 86 | const jsonInner = (s: string): string => JSON.stringify(s).slice(1, -1); | |
| 87 | ||
| 88 | /** | |
| 89 | * Replace the declared literals in the declared files, once each, as plain | |
| 90 | * text (a `.json` file gets the JSON-escaped form). Everything else is the | |
| 91 | * template's own bytes. Undefined when the template has no readable | |
| 92 | * `template.json`. | |
| 93 | */ | |
| 94 | export function fillTemplate(copy: TemplateCopy, input: ScaffoldInput): FileWrite[] | undefined { | |
| 95 | const spec = copy.files.find((f) => f.path === "template.json"); | |
| 96 | const parsed = spec === undefined ? undefined : parseTemplateSpec(text(spec)); | |
| 97 | if (parsed === undefined) return undefined; | |
| 98 | const description = input.description.trim() || parsed.description; | |
| 99 | const carriers = new Set(parsed.files); | |
| 100 | const out: FileWrite[] = []; | |
| 101 | for (const f of copy.files) { | |
| 102 | if (f.path === "template.json") continue; | |
| 103 | if (!carriers.has(f.path) || f.content === null) { | |
| 104 | out.push(f); | |
| 105 | continue; | |
| 106 | } | |
| 107 | const json = f.path.endsWith(".json"); | |
| 108 | const esc = (s: string): string => (json ? jsonInner(s) : s); | |
| 109 | // Description first: the name literal can sit inside it. | |
| 110 | const body = text(f) | |
| 111 | .split(esc(parsed.description)) | |
| 112 | .join("\u0000D\u0000") | |
| 113 | .split(esc(parsed.name)) | |
| 114 | .join(esc(input.name)) | |
| 115 | .split("\u0000D\u0000") | |
| 116 | .join(esc(description)); | |
| 117 | out.push({ ...f, content: body }); | |
| 118 | } | |
| 119 | return out; | |
| 120 | } | |
| 121 | ||
| 122 | let baked: TemplateCopy | undefined; | |
| 123 | ||
| 124 | /** The copy in the image. */ | |
| 125 | export function bakedTemplate(): TemplateCopy { | |
| 126 | if (baked) return baked; | |
| 127 | const raw = JSON.parse( | |
| 128 | readFileSync(new URL("../../templates/app-template.json", import.meta.url), "utf8"), | |
| 129 | ) as { repo: string; commit: string; pinned?: boolean; files: Record<string, string> }; | |
| 130 | baked = { | |
| 131 | from: raw.repo, | |
| 132 | commit: raw.commit, | |
| 133 | files: Object.entries(raw.files).map(([path, content]) => ({ path, content })), | |
| 134 | }; | |
| 135 | return baked; | |
| 136 | } | |
| 137 | ||
| 138 | /** The baked copy filled in for an app, synchronously. */ | |
| 139 | export function bakedFiles(input: ScaffoldInput): FileWrite[] { | |
| 140 | const files = fillTemplate(bakedTemplate(), input); | |
| 141 | if (!files) throw new Error("the baked app template has no readable template.json"); | |
| 142 | return files; | |
| 143 | } | |
| 144 | ||
| 145 | /** The template this forge names, `owner/name`. */ | |
| 146 | export function templateSlug(app: App): string { | |
| 147 | const row = app.db | |
| 148 | .query<{ value: string }, [string]>("SELECT value FROM forge_setting WHERE key = ?1") | |
| 149 | .get(TEMPLATE_SETTING); | |
| 150 | return row !== null && isTemplateSlug(row.value) ? row.value : DEFAULT_TEMPLATE; | |
| 151 | } | |
| 152 | ||
| 153 | /** The tracked files of `owner/name` at its default branch's tip, or undefined. */ | |
| 154 | export async function readTemplate(app: App, slug: string): Promise<TemplateCopy | undefined> { | |
| 155 | if (!isTemplateSlug(slug)) return undefined; | |
| 156 | const [owner, name] = slug.split("/") as [string, string]; | |
| 157 | const repo = getRepo(app.db, owner, name); | |
| 158 | if (!repo) return undefined; | |
| 159 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 160 | const rev = `refs/heads/${repo.defaultBranch}`; | |
| 161 | const listed = await listPaths(dir, rev, FILES_MAX + 1); | |
| 162 | if (!listed.ok || listed.value.truncated || listed.value.paths.length > FILES_MAX || listed.value.paths.length === 0) | |
| 163 | return undefined; | |
| 164 | const reader = readerFor(dir); | |
| 165 | const commit = await reader.stat(rev); | |
| 166 | if (!commit.ok) return undefined; | |
| 167 | const files: FileWrite[] = []; | |
| 168 | let total = 0; | |
| 169 | for (const path of listed.value.paths) { | |
| 170 | if (!safePath(path)) return undefined; | |
| 171 | const obj = await reader.object(`${rev}:${path}`); | |
| 172 | if (!obj.ok || obj.value.type !== "blob" || obj.value.body.length > FILE_BYTES_MAX) return undefined; | |
| 173 | total += obj.value.body.length; | |
| 174 | if (total > TOTAL_BYTES_MAX) return undefined; | |
| 175 | files.push({ path, content: obj.value.body }); | |
| 176 | } | |
| 177 | return { files, from: slug, commit: commit.value.oid }; | |
| 178 | } | |
| 179 | ||
| 180 | /** | |
| 181 | * The files for a new app: from the named template (default: the forge's), | |
| 182 | * else the baked copy. A repository whose `template.json` is missing or | |
| 183 | * unreadable is not a template, and the baked copy stands in. | |
| 184 | */ | |
| 185 | export async function scaffoldFiles( | |
| 186 | app: App, | |
| 187 | input: ScaffoldInput, | |
| 188 | slug: string = templateSlug(app), | |
| 189 | ): Promise<FileWrite[]> { | |
| 190 | const copy = await readTemplate(app, slug); | |
| 191 | return (copy && fillTemplate(copy, input)) ?? bakedFiles(input); | |
| 192 | } |
src/kernel/destroy_reach.ts −5
| @@ -83,11 +83,6 @@ export const ALLOWED_REMOVALS: readonly { unit: string; why: string }[] = [ | ||
| 83 | 83 | { unit: "src/spawn/own_code.ts:checkout", why: "removes a half-made export of the forge's own code, keyed by commit" }, |
| 84 | 84 | { unit: "src/spawn/own_code.ts:recordGood", why: "removes exports of the forge's own code for other commits than the good one" }, |
| 85 | 85 | { unit: "src/web/asset.ts:prune", why: "removes cached page scripts older than their keep time" }, |
| 86 | ...unitsOn("the call is inside text the unit returns (a generated app's code), never run by the forge", [ | |
| 87 | "src/apps/scaffold.ts:db", | |
| 88 | "src/apps/scaffold.ts:committed", | |
| 89 | "src/apps/scaffold.ts:base", | |
| 90 | ]), | |
| 91 | 86 | ]; |
| 92 | 87 | |
| 93 | 88 | const REMOVERS = ["rmSync", "rmdirSync", "unlinkSync", "rm", "rmdir", "unlink"]; |
src/kernel/signin_words.ts +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | * What a sign-in flow says when its return cannot be finished: the state |
| 3 | 3 | * did not come back to the browser that began, or the login it names is |
| 4 | 4 | * gone (spent, or past its time). The parent sign-in (`http/rp.ts`) and the |
| 5 | * sign-in an app's scaffold writes (`apps/scaffold.ts`) say the same thing, | |
| 5 | * sign-in an app's scaffold writes (`templates/app-template.json`) say the same thing, | |
| 6 | 6 | * so it is said here once. |
| 7 | 7 | */ |
| 8 | 8 | export const SIGNIN_NOT_THIS_BROWSER = "This sign-in did not start in this browser."; |
src/spawn/template_birth.ts +1 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { App } from "../app.ts"; |
| 11 | import { scaffoldFiles } from "../apps/scaffold.ts"; | |
| 11 | import { bakedFiles as scaffoldFiles } from "../apps/template.ts"; | |
| 12 | 12 | import { createRepo, getRepo, updateRepoSettings } from "../core/repos.ts"; |
| 13 | 13 | import { runGit } from "../git/env.ts"; |
| 14 | 14 | import { repoDir } from "../git/repos.ts"; |
templates/app-template.json added +55
Diff not shown — 236,122 bytes of diff (limit 100,000). +55
View filetest/app-template.test.ts added +199
| @@ -0,0 +1,199 @@ | ||
| 1 | /** | |
| 2 | * The app a new app starts as is a repository (#10305): the forge copies | |
| 3 | * the template repository's tip when it has one, and the copy in the image | |
| 4 | * otherwise; `template.json` says which literals are the app's name and | |
| 5 | * description; the template's own evaluator is the forge's. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; | |
| 8 | import { readFileSync } from "node:fs"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { newApp } from "../src/actions/apps.ts"; | |
| 14 | import { bakedFiles, bakedTemplate, fillTemplate, parseTemplateSpec, scaffoldFiles } from "../src/apps/template.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { readerFor } from "../src/git/read.ts"; | |
| 17 | import { repoDir } from "../src/git/repos.ts"; | |
| 18 | import { commitFiles } from "../src/git/write.ts"; | |
| 19 | ||
| 20 | setDefaultTimeout(30_000); | |
| 21 | ||
| 22 | const text = (c: string | Uint8Array | null | undefined): string => | |
| 23 | typeof c === "string" ? c : new TextDecoder().decode(c ?? new Uint8Array()); | |
| 24 | ||
| 25 | describe("the copy in the image", () => { | |
| 26 | const copy = bakedTemplate(); | |
| 27 | const spec = parseTemplateSpec(text(copy.files.find((f) => f.path === "template.json")?.content)); | |
| 28 | ||
| 29 | test("declares its literals and the files that carry them", () => { | |
| 30 | expect(spec?.name).toBe("app-template"); | |
| 31 | expect(spec?.files).toEqual(["package.json", "README.md", "contract/openapi.json"]); | |
| 32 | for (const path of spec?.files ?? []) expect(copy.files.some((f) => f.path === path)).toBe(true); | |
| 33 | }); | |
| 34 | ||
| 35 | test("filling replaces the literals in the named files and touches nothing else", () => { | |
| 36 | const out = bakedFiles({ name: "hello-there", description: 'A "quoted" place' }); | |
| 37 | expect(out.some((f) => f.path === "template.json")).toBe(false); | |
| 38 | const get = (path: string) => text(out.find((f) => f.path === path)?.content); | |
| 39 | expect(JSON.parse(get("package.json"))).toMatchObject({ name: "hello-there", description: 'A "quoted" place' }); | |
| 40 | expect(get("README.md").startsWith('# hello-there\n\nA "quoted" place\n')).toBe(true); | |
| 41 | expect(JSON.parse(get("contract/openapi.json")).info).toMatchObject({ title: "hello-there", description: 'A "quoted" place' }); | |
| 42 | const carriers = new Set(spec?.files); | |
| 43 | for (const f of copy.files) { | |
| 44 | if (f.path === "template.json" || carriers.has(f.path)) continue; | |
| 45 | expect(get(f.path)).toBe(text(f.content)); | |
| 46 | } | |
| 47 | }); | |
| 48 | ||
| 49 | test("an empty description keeps the template's own line", () => { | |
| 50 | const out = bakedFiles({ name: "x", description: " " }); | |
| 51 | expect(text(out.find((f) => f.path === "README.md")?.content)).toContain("A new app on the forge."); | |
| 52 | }); | |
| 53 | ||
| 54 | test("filling with its own literals gives back the template's files", () => { | |
| 55 | const out = fillTemplate(copy, { name: "app-template", description: "A new app on the forge." }) ?? []; | |
| 56 | for (const f of out) expect(text(f.content)).toBe(text(copy.files.find((c) => c.path === f.path)?.content)); | |
| 57 | }); | |
| 58 | ||
| 59 | test("its contract evaluator and beacon are the forge's own", async () => { | |
| 60 | const mine = (p: string) => Bun.file(join(import.meta.dir, "..", p)).text(); | |
| 61 | const can = copy.files.find((f) => f.path === "src/can.ts")?.content; | |
| 62 | expect(text(can)).toBe(await mine("src/contract/can.ts")); | |
| 63 | }); | |
| 64 | ||
| 65 | test("a template.json that names odd paths or too many files is not read", () => { | |
| 66 | expect(parseTemplateSpec("nope")).toBeUndefined(); | |
| 67 | for (const bad of ["../x", "/abs", ".git/config", "a/../b", "a//b", "a\\b", "x/.GIT/y"]) | |
| 68 | expect(parseTemplateSpec(JSON.stringify({ name: "a", description: "b", files: [bad] }))).toBeUndefined(); | |
| 69 | const many = Array.from({ length: 51 }, (_, i) => `f${i}`); | |
| 70 | expect(parseTemplateSpec(JSON.stringify({ name: "a", description: "b", files: many }))).toBeUndefined(); | |
| 71 | }); | |
| 72 | }); | |
| 73 | ||
| 74 | describe("New app copies the template repository when the forge has it", () => { | |
| 75 | let dataDir: string; | |
| 76 | let app: App; | |
| 77 | let owner: User; | |
| 78 | ||
| 79 | beforeAll(async () => { | |
| 80 | dataDir = await mkdtemp(join(tmpdir(), "forge-template-")); | |
| 81 | app = createApp({ dataDir, port: 0 }); | |
| 82 | const made = await createUser(app.db, { | |
| 83 | origin: "seed", | |
| 84 | trusted: true, | |
| 85 | username: "trevato", | |
| 86 | email: "[email protected]", | |
| 87 | password: "a-good-passphrase", | |
| 88 | adminIfFirst: true, | |
| 89 | }); | |
| 90 | if (!made.ok) throw new Error(made.error); | |
| 91 | owner = made.value; | |
| 92 | }); | |
| 93 | afterAll(async () => { | |
| 94 | app.close(); | |
| 95 | await rm(dataDir, { recursive: true, force: true }); | |
| 96 | }); | |
| 97 | ||
| 98 | const readme = async (name: string): Promise<string> => { | |
| 99 | const got = await readerFor(repoDir(app.reposRoot, "trevato", name)).object("refs/heads/main:README.md"); | |
| 100 | return got.ok ? text(got.value.body) : ""; | |
| 101 | }; | |
| 102 | ||
| 103 | test("without the repository the image's copy makes the app", async () => { | |
| 104 | const files = await scaffoldFiles(app, { name: "fallback", description: "" }); | |
| 105 | expect(files.map((f) => f.path).sort()).toEqual(bakedFiles({ name: "fallback", description: "" }).map((f) => f.path).sort()); | |
| 106 | const made = await newApp(app, owner, { name: "from-baked", description: "Baked" }); | |
| 107 | expect(made.ok).toBe(true); | |
| 108 | expect(await readme("from-baked")).toStartWith("# from-baked\n\nBaked\n"); | |
| 109 | }); | |
| 110 | ||
| 111 | test("with it, the tip of its default branch is the app, in one commit", async () => { | |
| 112 | // trevato/app-template: made as an app, then given a README the image does not have. | |
| 113 | expect((await newApp(app, owner, { name: "app-template" })).ok).toBe(true); | |
| 114 | const edited = text(bakedTemplate().files.find((f) => f.path === "README.md")?.content).replace( | |
| 115 | "# app-template", | |
| 116 | "# app-template\n\nTIP-MARKER", | |
| 117 | ); | |
| 118 | const template = bakedTemplate().files.map((f) => (f.path === "README.md" ? { ...f, content: edited } : f)); | |
| 119 | await commitFiles(repoDir(app.reposRoot, "trevato", "app-template"), { | |
| 120 | branch: "main", | |
| 121 | message: "Change the template", | |
| 122 | files: template, | |
| 123 | author: { name: "trevato", email: "[email protected]" }, | |
| 124 | }); | |
| 125 | const child = await newApp(app, owner, { name: "child-one", description: "Kid" }); | |
| 126 | expect(child.ok).toBe(true); | |
| 127 | const body = await readme("child-one"); | |
| 128 | expect(body).toContain("TIP-MARKER"); | |
| 129 | expect(body).toStartWith("# child-one\n\nTIP-MARKER"); | |
| 130 | const count = Bun.spawnSync(["git", "-C", repoDir(app.reposRoot, "trevato", "child-one"), "rev-list", "--count", "main"]); | |
| 131 | expect(count.stdout.toString().trim()).toBe("1"); | |
| 132 | const tracked = Bun.spawnSync(["git", "-C", repoDir(app.reposRoot, "trevato", "child-one"), "ls-tree", "-r", "--name-only", "main"]); | |
| 133 | expect(tracked.stdout.toString()).not.toContain("template.json"); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a template repository without a readable template.json falls back to the image's copy", async () => { | |
| 137 | await commitFiles(repoDir(app.reposRoot, "trevato", "app-template"), { | |
| 138 | branch: "main", | |
| 139 | message: "Break template.json", | |
| 140 | files: [{ path: "template.json", content: "nope" }], | |
| 141 | author: { name: "trevato", email: "[email protected]" }, | |
| 142 | }); | |
| 143 | expect((await newApp(app, owner, { name: "child-two" })).ok).toBe(true); | |
| 144 | expect(await readme("child-two")).not.toContain("TIP-MARKER"); | |
| 145 | }); | |
| 146 | }); | |
| 147 | ||
| 148 | describe("the copy in the image is pinned or says it is not", () => { | |
| 149 | const raw = JSON.parse(readFileSync(new URL("../templates/app-template.json", import.meta.url), "utf8")) as { | |
| 150 | commit: string; | |
| 151 | pinned?: boolean; | |
| 152 | note?: string; | |
| 153 | }; | |
| 154 | ||
| 155 | test("a pinned bundle names a full commit id; an unpinned one says so in a note", () => { | |
| 156 | if (raw.pinned === true) expect(raw.commit).toMatch(/^[0-9a-f]{40}$/); | |
| 157 | else expect(raw.note ?? "").toContain("Not yet taken from the repository"); | |
| 158 | }); | |
| 159 | }); | |
| 160 | ||
| 161 | describe("the two template.json forms", () => { | |
| 162 | test("the root form is not read as a folder's and the folder's is not read as the root's", () => { | |
| 163 | const root = { name: "app-template", description: "A new app.", files: ["README.md"] }; | |
| 164 | const folder = { files: ["README.md"], literals: ["{{name}}", "{{description}}"] }; | |
| 165 | expect(parseTemplateSpec(JSON.stringify(root))).toBeDefined(); | |
| 166 | expect(parseTemplateSpec(JSON.stringify(folder))).toBeUndefined(); | |
| 167 | expect(Object.keys(folder).sort()).toEqual(["files", "literals"]); | |
| 168 | }); | |
| 169 | }); | |
| 170 | ||
| 171 | describe("a template copy over the caps is not used", () => { | |
| 172 | let dataDir: string; | |
| 173 | let app: App; | |
| 174 | beforeAll(async () => { | |
| 175 | dataDir = await mkdtemp(join(tmpdir(), "forge-template-caps-")); | |
| 176 | app = createApp({ dataDir, port: 0 }); | |
| 177 | const made = await createUser(app.db, { | |
| 178 | origin: "seed", trusted: true, username: "trevato", email: "[email protected]", | |
| 179 | password: "a-good-passphrase", adminIfFirst: true, | |
| 180 | }); | |
| 181 | if (!made.ok) throw new Error(made.error); | |
| 182 | expect((await newApp(app, made.value, { name: "app-template" })).ok).toBe(true); | |
| 183 | }); | |
| 184 | afterAll(async () => { | |
| 185 | app.close(); | |
| 186 | await rm(dataDir, { recursive: true, force: true }); | |
| 187 | }); | |
| 188 | ||
| 189 | test("a file over the per-file cap sends New app to the image's copy", async () => { | |
| 190 | await commitFiles(repoDir(app.reposRoot, "trevato", "app-template"), { | |
| 191 | branch: "main", | |
| 192 | message: "Too big", | |
| 193 | files: [{ path: "big.bin", content: new Uint8Array(512 * 1024 + 1) }], | |
| 194 | author: { name: "trevato", email: "[email protected]" }, | |
| 195 | }); | |
| 196 | const got = await scaffoldFiles(app, { name: "capped", description: "" }); | |
| 197 | expect(got.some((f) => f.path === "big.bin")).toBe(false); | |
| 198 | }); | |
| 199 | }); |
test/apps-measure.test.ts +1 −1
| @@ -12,7 +12,7 @@ import { join } from "node:path"; | ||
| 12 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 13 | import { newApp } from "../src/actions/apps.ts"; |
| 14 | 14 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| 15 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; | |
| 15 | import { bakedFiles as scaffoldFiles } from "../src/apps/template.ts"; | |
| 16 | 16 | import { appVisitors } from "../src/actions/apps.ts"; |
| 17 | 17 | import { createToken } from "../src/core/tokens.ts"; |
| 18 | 18 | import { getRepo } from "../src/core/repos.ts"; |
test/apps-new.test.ts +1 −1
| @@ -24,7 +24,7 @@ import { getRepo } from "../src/core/repos.ts"; | ||
| 24 | 24 | import { createUser, type User } from "../src/core/users.ts"; |
| 25 | 25 | import { serve, type Servers } from "../src/http/server.ts"; |
| 26 | 26 | |
| 27 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; | |
| 27 | import { bakedFiles as scaffoldFiles } from "../src/apps/template.ts"; | |
| 28 | 28 | import { parseCheck } from "../src/apps/manifest.ts"; |
| 29 | 29 | import { |
| 30 | 30 | appNameJs, |
test/apps-token.test.ts +1 −1
| @@ -23,7 +23,7 @@ import { | ||
| 23 | 23 | renewAppToken, |
| 24 | 24 | revokeAppToken, |
| 25 | 25 | } from "../src/apps/token.ts"; |
| 26 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; | |
| 26 | import { bakedFiles as scaffoldFiles } from "../src/apps/template.ts"; | |
| 27 | 27 | import { newApp } from "../src/actions/apps.ts"; |
| 28 | 28 | import { openIssue } from "../src/actions/issues.ts"; |
| 29 | 29 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
test/contract.test.ts +1 −1
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | */ |
| 11 | 11 | import { describe, expect, test } from "bun:test"; |
| 12 | 12 | import { readFileSync } from "node:fs"; |
| 13 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; | |
| 13 | import { bakedFiles as scaffoldFiles } from "../src/apps/template.ts"; | |
| 14 | 14 | import { policyLines, taskPrompt } from "../src/crew/brief.ts"; |
| 15 | 15 | import { |
| 16 | 16 | NOBODY, |
test/cookie-flags.test.ts +1 −3
| @@ -42,7 +42,6 @@ const WALKED: Record<string, number> = { | ||
| 42 | 42 | }; |
| 43 | 43 | const UNREACHED: Record<string, { sites: number; why: string }> = { |
| 44 | 44 | "src/http/addressed.ts": { sites: 1, why: "only asks whether a response sets a cookie, so it isn't shared; it sets none" }, |
| 45 | "src/apps/scaffold.ts": { sites: 7, why: "the `notice` cookie lives in the template generated into new apps, not in the forge's own responses" }, | |
| 46 | 45 | "src/apps/feedback.ts": { sites: 2, why: "the visitor cookie is set on a deployed app's own host from its feedback widget; covered by the feedback tests" }, |
| 47 | 46 | "src/web/pages/claim.ts": { sites: 1, why: "claiming the first admin seat needs a fresh forge with a claim link; same sessionCookie() as the walked doors" }, |
| 48 | 47 | "src/web/pages/first-admin.ts": { sites: 1, why: "first-admin recovery needs a forge with no admin; same sessionCookie() as the walked doors" }, |
| @@ -234,7 +233,6 @@ const BUILDERS: Record<string, string> = { | ||
| 234 | 233 | "clearFlashCookie(": "src/web/flash.ts", |
| 235 | 234 | "cookieHeader(": "src/http/rp.ts", |
| 236 | 235 | "visitorCookie(": "src/apps/feedback.ts", |
| 237 | "cookie(": "src/apps/scaffold.ts, the scaffolded app's own `cookie`", | |
| 238 | 236 | clear: "src/http/rp.ts, `cookieHeader` with Max-Age 0", |
| 239 | 237 | value: "src/apps/scaffold.ts, `redirectTo`'s cookies, each from `cookie`", |
| 240 | 238 | cookie: "src/http/measure.ts, the line `trackVisitor` returns, built by `cookieLine` in src/perf/visitor_id.ts", |
| @@ -268,7 +266,7 @@ describe("cookie flags, walked in the source", () => { | ||
| 268 | 266 | |
| 269 | 267 | test("the walk finds the forge's cookies, including those no door above reaches", () => { |
| 270 | 268 | const where = new Set(spelled.map((s) => s.where.replace(/:\d+$/, ""))); |
| 271 | for (const file of ["src/web/pages/site.ts", "src/web/flash.ts", "src/http/rp.ts", "src/http/server.ts", "src/apps/proxy.ts", "src/apps/feedback.ts", "src/apps/scaffold.ts", "src/web/pages/map.ts"]) | |
| 269 | for (const file of ["src/web/pages/site.ts", "src/web/flash.ts", "src/http/rp.ts", "src/http/server.ts", "src/apps/proxy.ts", "src/apps/feedback.ts", "src/web/pages/map.ts"]) | |
| 272 | 270 | expect([file, where.has(file)]).toEqual([file, true]); |
| 273 | 271 | }); |
| 274 | 272 |
test/job-slices.test.ts +1 −1
| @@ -247,7 +247,6 @@ const UNTIMED: Record<string, string> = { | ||
| 247 | 247 | "release-behind": "reads the forge's own release tags with git; needs FORGE_REPO and its repository", |
| 248 | 248 | "release-keeper": "cluster only (FORGE_RELEASE_KEEPER=on): talks to the cluster's API", |
| 249 | 249 | "roll-probe": "cluster only (FORGE_RELEASE_KEEPER=on): one HTTP probe", |
| 250 | "spawn-due-wake": "one child's wake when its paused run falls due; the fixture has no children", | |
| 251 | 250 | "apps-sweep": "AppsRuntime's sweep over running app processes; the fixture has no apps", |
| 252 | 251 | "cell-watch": "watches running app cells' cgroups; needs cells, skipped in this sandbox mode", |
| 253 | 252 | "cell-probe-deadline": "a cell's start probe running out of time: needs cells, skipped in this sandbox mode", |
| @@ -275,6 +274,7 @@ const UNTIMED: Record<string, string> = { | ||
| 275 | 274 | "system-live": "reads /proc and the cgroup roots for the system page while someone watches it", |
| 276 | 275 | entitlements: "reads each paid subscription from Stripe: network, daily, and the fixture has no entitlements", |
| 277 | 276 | traffic: "a closure in createTrafficWriter over its in-memory minute ledger; follow-up", |
| 277 | "spawn-due-wake": "a timer in the spawn host that wakes a due run: needs a spawned run; the fixture has none", | |
| 278 | 278 | }; |
| 279 | 279 | |
| 280 | 280 | /** A job held past this in one slice fails the test: five times the issue's 50 ms, so no runner flakes it. */ |
test/probe.test.ts +1 −1
| @@ -13,7 +13,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 13 | 13 | import { newApp } from "../src/actions/apps.ts"; |
| 14 | 14 | import { commitToBranch } from "../src/actions/repos.ts"; |
| 15 | 15 | import { AppsRuntime, appsRuntime } from "../src/apps/runtime.ts"; |
| 16 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; | |
| 16 | import { bakedFiles as scaffoldFiles } from "../src/apps/template.ts"; | |
| 17 | 17 | import { audit } from "../src/contract/audit.ts"; |
| 18 | 18 | import { probe, type Probed } from "../src/contract/probe.ts"; |
| 19 | 19 | import { getAppByRepo } from "../src/core/apps.ts"; |
test/scaffold-webspec.test.ts +1 −1
| @@ -8,7 +8,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; | ||
| 8 | 8 | import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { dirname, join } from "node:path"; |
| 11 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; | |
| 11 | import { bakedFiles as scaffoldFiles } from "../src/apps/template.ts"; | |
| 12 | 12 | import { webspec, type Finding } from "../src/release/webspec.ts"; |
| 13 | 13 | |
| 14 | 14 | /** Today's gaps, each with its specification.website section. None: the scaffold meets it. */ |
test/scaffold.test.ts +1 −1
| @@ -23,7 +23,7 @@ import { parseManifest } from "../src/apps/manifest.ts"; | ||
| 23 | 23 | import { createSession } from "../src/core/sessions.ts"; |
| 24 | 24 | import { createUser } from "../src/core/users.ts"; |
| 25 | 25 | import { serve } from "../src/http/server.ts"; |
| 26 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; | |
| 26 | import { bakedFiles as scaffoldFiles } from "../src/apps/template.ts"; | |
| 27 | 27 | |
| 28 | 28 | /** How long the written-out app's `bun test` may take, wall clock. */ |
| 29 | 29 | const SUITE_BUDGET_MS = 3_000; |
test/security-floor-doors.test.ts −1
| @@ -33,7 +33,6 @@ const LISTENERS: Record<string, { count: number; why: string }> = { | ||
| 33 | 33 | "src/spawn/beat-door.ts": { count: 1, why: "fetch is `securityFloor(await answerBeat(...))`, asked below" }, |
| 34 | 34 | "src/kernel/port.ts": { count: 1, why: "a port probe, stopped before any request can reach it" }, |
| 35 | 35 | "src/apps/process.ts": { count: 1, why: "a port probe, stopped before any request can reach it" }, |
| 36 | "src/apps/scaffold.ts": { count: 1, why: "text of another app's server, written into its repository; it lays its own floor" }, | |
| 37 | 36 | "src/actions/spawn.ts": { count: 1, why: "text of a fresh app's index.ts, written into its repository" }, |
| 38 | 37 | }; |
| 39 | 38 |
test/signin-words.test.ts +10 −1
| @@ -6,9 +6,10 @@ | ||
| 6 | 6 | import { describe, expect, test } from "bun:test"; |
| 7 | 7 | import { readFileSync } from "node:fs"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | import { bakedTemplate } from "../src/apps/template.ts"; | |
| 9 | 10 | import * as words from "../src/kernel/signin_words.ts"; |
| 10 | 11 | |
| 11 | const READERS = ["src/http/rp.ts", "src/apps/scaffold.ts"]; | |
| 12 | const READERS = ["src/http/rp.ts"]; | |
| 12 | 13 | const source = (path: string): string => |
| 13 | 14 | readFileSync(join(import.meta.dir, "..", path), "utf8"); |
| 14 | 15 | |
| @@ -33,3 +34,11 @@ describe("a sign-in flow's expiry sentences", () => { | ||
| 33 | 34 | } |
| 34 | 35 | }); |
| 35 | 36 | }); |
| 37 | ||
| 38 | describe("the app template says the same sentences", () => { | |
| 39 | test("its sign-in carries each one verbatim", () => { | |
| 40 | const signin = bakedTemplate().files.find((f) => f.path === "src/signin.ts")?.content; | |
| 41 | expect(typeof signin).toBe("string"); | |
| 42 | for (const [, sentence] of Object.entries(words)) expect(signin as string).toContain(sentence); | |
| 43 | }); | |
| 44 | }); |
test/spawn-template-birth.test.ts +1 −1
| @@ -11,7 +11,7 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { createRepo } from "../src/actions/repos.ts"; |
| 12 | 12 | import { childDirOf, spawnForge, type SpawnDeps } from "../src/actions/spawn.ts"; |
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | import { scaffoldFiles } from "../src/apps/scaffold.ts"; | |
| 14 | import { bakedFiles as scaffoldFiles } from "../src/apps/template.ts"; | |
| 15 | 15 | import { getAppByRepo } from "../src/core/apps.ts"; |
| 16 | 16 | import { getRepo } from "../src/core/repos.ts"; |
| 17 | 17 | import { createToken } from "../src/core/tokens.ts"; |
test/template-scripts.test.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | /** | |
| 2 | * scripts/take-template.ts and scripts/birth-check.ts (#10305): the only | |
| 3 | * checks that a template change still bundles and still boots. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; | |
| 6 | import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { dirname, join } from "node:path"; | |
| 9 | import { bakedTemplate } from "../src/apps/template.ts"; | |
| 10 | ||
| 11 | setDefaultTimeout(60_000); | |
| 12 | ||
| 13 | let root: string; | |
| 14 | const git = (dir: string, ...args: string[]) => | |
| 15 | Bun.spawnSync(["git", "-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], { stdout: "pipe", stderr: "pipe" }); | |
| 16 | ||
| 17 | async function checkout(name: string, extra: Record<string, Uint8Array | string> = {}): Promise<string> { | |
| 18 | const dir = join(root, name); | |
| 19 | await mkdir(dir, { recursive: true }); | |
| 20 | git(dir, "init", "-q", "-b", "main"); | |
| 21 | const all: Record<string, string | Uint8Array> = {}; | |
| 22 | for (const f of bakedTemplate().files) all[f.path] = typeof f.content === "string" ? f.content : ""; | |
| 23 | Object.assign(all, extra); | |
| 24 | for (const [p, c] of Object.entries(all)) { | |
| 25 | await mkdir(dirname(join(dir, p)), { recursive: true }); | |
| 26 | await writeFile(join(dir, p), c); | |
| 27 | } | |
| 28 | git(dir, "add", "-A"); | |
| 29 | git(dir, "commit", "-q", "-m", "t"); | |
| 30 | return dir; | |
| 31 | } | |
| 32 | ||
| 33 | const run = (script: string, args: string[], env: Record<string, string> = {}) => | |
| 34 | Bun.spawnSync(["bun", join(import.meta.dir, "..", "scripts", script), ...args], { | |
| 35 | env: { ...process.env, ...env }, | |
| 36 | stdout: "pipe", | |
| 37 | stderr: "pipe", | |
| 38 | }); | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | root = await mkdtemp(join(process.env["TMPDIR"] ?? tmpdir(), "tpl-scripts-")); | |
| 42 | }); | |
| 43 | afterAll(() => rm(root, { recursive: true, force: true })); | |
| 44 | ||
| 45 | test("take-template records HEAD as a pinned commit and the tracked files", async () => { | |
| 46 | const dir = await checkout("take"); | |
| 47 | const out = join(root, "take.json"); | |
| 48 | const r = run("take-template.ts", [dir], { FORGE_TEMPLATE_OUT: out }); | |
| 49 | expect(r.exitCode).toBe(0); | |
| 50 | const bundle = JSON.parse(await Bun.file(out).text()); | |
| 51 | expect(bundle.commit).toBe(git(dir, "rev-parse", "HEAD").stdout.toString().trim()); | |
| 52 | expect(bundle.commit).toMatch(/^[0-9a-f]{40}$/); | |
| 53 | expect(bundle.pinned).toBe(true); | |
| 54 | expect(Object.keys(bundle.files).sort()).toEqual(bakedTemplate().files.map((f) => f.path).sort()); | |
| 55 | }); | |
| 56 | ||
| 57 | test("take-template refuses a binary file and a checkout without template.json", async () => { | |
| 58 | const bin = await checkout("bin", { "icon.png": new Uint8Array([0x89, 0x50, 0xff, 0xfe, 0x00]) }); | |
| 59 | const out = join(root, "bin.json"); | |
| 60 | const r = run("take-template.ts", [bin], { FORGE_TEMPLATE_OUT: out }); | |
| 61 | expect(r.exitCode).toBe(1); | |
| 62 | expect(r.stderr.toString()).toContain("icon.png"); | |
| 63 | expect(await Bun.file(out).exists()).toBe(false); | |
| 64 | const none = join(root, "none"); | |
| 65 | await mkdir(none); | |
| 66 | git(none, "init", "-q"); | |
| 67 | await writeFile(join(none, "a.txt"), "x"); | |
| 68 | git(none, "add", "-A"); | |
| 69 | git(none, "commit", "-q", "-m", "t"); | |
| 70 | expect(run("take-template.ts", [none], { FORGE_TEMPLATE_OUT: join(root, "none.json") }).exitCode).toBe(1); | |
| 71 | }); | |
| 72 | ||
| 73 | test("birth-check boots the born app and exits 0; a template that does not boot exits 1", async () => { | |
| 74 | const good = await checkout("good"); | |
| 75 | const ok = run("birth-check.ts", [good], { TMPDIR: root }); | |
| 76 | expect(ok.stdout.toString()).toContain("booted"); | |
| 77 | expect(ok.exitCode).toBe(0); | |
| 78 | const bad = await checkout("bad", { "src/server.ts": "throw new Error('does not boot');\n" }); | |
| 79 | expect(run("birth-check.ts", [bad], { TMPDIR: root }).exitCode).toBe(1); | |
| 80 | }); |
new above
offline