Public: anyone can read this; the crew can change it.
New app copies a template repository's tip (readable by the caller, capped, no symlinks), with a door to name it #10604
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10421-new-app-copies-a-template-repository-s-t → main 5 commits fork 4 threads · 4 unresolved
docs/APPS.md +1
| @@ -506,6 +506,7 @@ and `pick`: `pick: true` turns the description into a checklist and lets the | ||
| 506 | 506 | plan pick the agent (ignored when `agent` is given or there is no |
| 507 | 507 | description). The 201 answer carries `plan: {agent, summary, confidence}` when |
| 508 | 508 | a pick happened and `plan: null` otherwise, planning being unavailable included. |
| 509 | `template: "owner/name"` starts the app as a copy of that repository's tip, if the caller can read it (see `docs/features/app-template.md`). | |
| 509 | 510 | |
| 510 | 511 | ## How an app is declared |
| 511 | 512 |
docs/features/app-template.md added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | # New app from a template | |
| 2 | ||
| 3 | A new app can start as a copy of another repository's tip instead of the starter app. | |
| 4 | ||
| 5 | - **Name it:** `template: "owner/name"` on `POST /api/v1/apps` and the MCP `new_app` tool, or `/apps/new?template=owner/name` on the page (a read-only field carries it through the form). | |
| 6 | - **Readable by the caller.** A template the caller cannot read answers `no_such_template`, the same as one that does not exist; a private name is never confirmed. | |
| 7 | - **Capped.** At most 500 files, 512 KiB per file, 4 MiB in all; over any of them is `template_too_big`, which names all three. | |
| 8 | - **No symlinks.** A symbolic link is `template_has_symlink`; a submodule or any other non-file is `template_unsupported`. | |
| 9 | - **Refused before anything exists.** The tip is read before the repository is made, so a refusal leaves nothing to take back. | |
| 10 | - **Doors:** the page, the API, MCP, and the terminal palette entry "New app from a template" (`templateAppScreen`). | |
| 11 | - **Read in one pass:** one `ls-tree` and one `cat-file --batch`, each output capped (`maxBytes`); a blob that will not read is `template_unreadable`. | |
| 12 | - Code: `src/apps/template_tip.ts`, `newApp` in `src/actions/apps.ts`. Tests: `test/actions-newapp-template.test.ts`, `test/apps-template-doors.test.ts`. |
src/actions/apps.ts +20 −4
| @@ -23,6 +23,8 @@ import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts"; | ||
| 23 | 23 | import { grantUrl, originFor, sideForViewer, sideReader } from "../apps/proxy.ts"; |
| 24 | 24 | import { plainLine } from "../apps/side.ts"; |
| 25 | 25 | import { scaffoldFiles } from "../apps/scaffold.ts"; |
| 26 | import type { FileWrite } from "../git/write.ts"; | |
| 27 | import { TEMPLATE_ERRORS, templateFiles, type TemplateError } from "../apps/template_tip.ts"; | |
| 26 | 28 | import { |
| 27 | 29 | envBuild, |
| 28 | 30 | isEnvName, |
| @@ -270,6 +272,8 @@ export interface NewAppInput { | ||
| 270 | 272 | * also given. |
| 271 | 273 | */ |
| 272 | 274 | pickAgent?: boolean; |
| 275 | /** `owner/name` of a repository the actor can read: its tip is copied instead of the starter app. */ | |
| 276 | template?: string; | |
| 273 | 277 | } |
| 274 | 278 | |
| 275 | 279 | export interface NewAppResult { |
| @@ -285,6 +289,7 @@ export type NewAppError = | ||
| 285 | 289 | | CreateRepoActionError |
| 286 | 290 | | CreateAppError |
| 287 | 291 | | "scaffold_failed" |
| 292 | | TemplateError | |
| 288 | 293 | | "no_such_agent" |
| 289 | 294 | | "not_a_builder" |
| 290 | 295 | | "app_limit"; |
| @@ -328,6 +333,14 @@ export async function newApp( | ||
| 328 | 333 | ) |
| 329 | 334 | return err("slug_taken"); |
| 330 | 335 | |
| 336 | // Read the template before a repository exists to take back. | |
| 337 | let starter: FileWrite[] | undefined; | |
| 338 | if (input.template !== undefined && input.template !== "") { | |
| 339 | const copied = await templateFiles(app, user.value, input.template); | |
| 340 | if (!copied.ok) return copied; | |
| 341 | starter = copied.value; | |
| 342 | } | |
| 343 | ||
| 331 | 344 | let mark = performance.now(); |
| 332 | 345 | const lap = (name: string): void => { |
| 333 | 346 | const now = performance.now(); |
| @@ -344,10 +357,12 @@ export async function newApp( | ||
| 344 | 357 | if (!repo.ok) return repo; |
| 345 | 358 | const committed = await commitToBranch(app, user.value, repo.value, { |
| 346 | 359 | message: `Start ${input.name}`, |
| 347 | files: scaffoldFiles({ | |
| 348 | name: input.name, | |
| 349 | description: input.description ?? "", | |
| 350 | }), | |
| 360 | files: | |
| 361 | starter ?? | |
| 362 | scaffoldFiles({ | |
| 363 | name: input.name, | |
| 364 | description: input.description ?? "", | |
| 365 | }), | |
| 351 | 366 | }); |
| 352 | 367 | lap("scaffold"); |
| 353 | 368 | if (!committed.ok) { |
| @@ -462,6 +477,7 @@ export const NEW_APP_ERRORS: Record<string, string> = { | ||
| 462 | 477 | "Another app already answers to that hostname. Pick another name.", |
| 463 | 478 | already_app: "That repository is already an app.", |
| 464 | 479 | scaffold_failed: "The starter app could not be committed.", |
| 480 | ...TEMPLATE_ERRORS, | |
| 465 | 481 | no_such_agent: NO_SUCH_AGENT, |
| 466 | 482 | not_a_builder: "That agent is not a builder, so it cannot be handed a new app. Pick a builder.", |
| 467 | 483 | }; |
src/apps/template_tip.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * A template repository's tip, read as the files a new app starts with. | |
| 3 | * Three refusals keep the copy safe: the caller must be able to read the | |
| 4 | * template (anything else answers `no_such_template`, so a private name is | |
| 5 | * never confirmed), the tip must fit the caps, and it holds no symlink or | |
| 6 | * submodule, which would point outside the new repository. | |
| 7 | */ | |
| 8 | ||
| 9 | import { canRead, getRepo } from "../core/repos.ts"; | |
| 10 | import type { User } from "../core/users.ts"; | |
| 11 | import type { App } from "../app.ts"; | |
| 12 | import { OVER_CAP, runGitRaw } from "../git/env.ts"; | |
| 13 | import { isSafeTreePath } from "../git/guard.ts"; | |
| 14 | import { repoDir } from "../git/repos.ts"; | |
| 15 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 16 | import type { FileWrite } from "../git/write.ts"; | |
| 17 | ||
| 18 | /** Most files a template may hold. */ | |
| 19 | export const TEMPLATE_MAX_FILES = 500; | |
| 20 | /** Most bytes one template file may hold. */ | |
| 21 | export const TEMPLATE_MAX_FILE_BYTES = 512 * 1024; | |
| 22 | /** Most bytes the whole template may hold. */ | |
| 23 | export const TEMPLATE_MAX_BYTES = 4 * 1024 * 1024; | |
| 24 | ||
| 25 | /** Most bytes `ls-tree` may print: a path and its numbers fit in 1 KiB. */ | |
| 26 | const TEMPLATE_MAX_LIST_BYTES = (TEMPLATE_MAX_FILES + 1) * 1024; | |
| 27 | ||
| 28 | export type TemplateError = | |
| 29 | | "no_such_template" | |
| 30 | | "template_empty" | |
| 31 | | "template_unreadable" | |
| 32 | | "template_too_big" | |
| 33 | | "template_has_symlink" | |
| 34 | | "template_unsupported"; | |
| 35 | ||
| 36 | /** Error code → the words a person is shown. */ | |
| 37 | export const TEMPLATE_ERRORS: Record<TemplateError, string> = { | |
| 38 | no_such_template: "No template by that name that you can read. Name it owner/name.", | |
| 39 | template_empty: "That template has no files yet.", | |
| 40 | template_unreadable: "That template could not be read. Try again.", | |
| 41 | template_too_big: `That template is over the cap (${TEMPLATE_MAX_FILES} files, ${TEMPLATE_MAX_FILE_BYTES / 1024} KiB a file, ${TEMPLATE_MAX_BYTES / 1024 / 1024} MiB in all).`, | |
| 42 | template_has_symlink: "That template holds a symbolic link, which is not copied.", | |
| 43 | template_unsupported: "That template holds something that is not a plain file.", | |
| 44 | }; | |
| 45 | ||
| 46 | /** `owner/name`, each part plain; anything else is not a template name. */ | |
| 47 | export function parseTemplateName(text: string): { owner: string; name: string } | undefined { | |
| 48 | const m = /^([A-Za-z0-9][A-Za-z0-9._-]{0,99})\/([A-Za-z0-9][A-Za-z0-9._-]{0,99})$/.exec(text.trim()); | |
| 49 | return m ? { owner: m[1]!, name: m[2]! } : undefined; | |
| 50 | } | |
| 51 | ||
| 52 | export async function templateFiles( | |
| 53 | app: App, | |
| 54 | actor: User | undefined, | |
| 55 | named: string, | |
| 56 | ): Promise<Result<FileWrite[], TemplateError>> { | |
| 57 | const parsed = parseTemplateName(named); | |
| 58 | if (!parsed) return err("no_such_template"); | |
| 59 | const repo = getRepo(app.db, parsed.owner, parsed.name); | |
| 60 | if (!repo || !canRead(app.db, repo, actor)) return err("no_such_template"); | |
| 61 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 62 | const rev = `refs/heads/${repo.defaultBranch}`; | |
| 63 | const listed = await runGitRaw(["ls-tree", "-r", "-z", "-l", rev], { | |
| 64 | cwd: dir, | |
| 65 | timeoutMs: 20_000, | |
| 66 | maxBytes: TEMPLATE_MAX_LIST_BYTES, | |
| 67 | }); | |
| 68 | if (!listed.ok) return err(listed.error.code === OVER_CAP ? "template_too_big" : "template_empty"); | |
| 69 | ||
| 70 | const entries: { mode: string; oid: string; size: number; path: string }[] = []; | |
| 71 | let total = 0; | |
| 72 | for (const record of new TextDecoder().decode(listed.value).split("\0")) { | |
| 73 | if (record === "") continue; | |
| 74 | const tab = record.indexOf("\t"); | |
| 75 | if (tab === -1) continue; | |
| 76 | const [mode = "", , oid = "", size = "0"] = record.slice(0, tab).split(/ +/); | |
| 77 | const path = record.slice(tab + 1); | |
| 78 | if (mode === "120000") return err("template_has_symlink"); | |
| 79 | if (mode !== "100644" && mode !== "100755") return err("template_unsupported"); | |
| 80 | if (!isSafeTreePath(path)) return err("template_unsupported"); | |
| 81 | const bytes = Number(size); | |
| 82 | total += bytes; | |
| 83 | if ( | |
| 84 | entries.length + 1 > TEMPLATE_MAX_FILES || | |
| 85 | bytes > TEMPLATE_MAX_FILE_BYTES || | |
| 86 | total > TEMPLATE_MAX_BYTES | |
| 87 | ) | |
| 88 | return err("template_too_big"); | |
| 89 | entries.push({ mode, oid, size: bytes, path }); | |
| 90 | } | |
| 91 | if (entries.length === 0) return err("template_empty"); | |
outdated
This starts one git read per file, up to 500 sequential spawns, on the request path. Batch them with a single |
||
| 92 | ||
| 93 | // One process reads every blob: `<oid> <type> <size>\n<body>\n` per entry. | |
| 94 | const raw = await runGitRaw(["cat-file", "--batch"], { | |
| 95 | cwd: dir, | |
| 96 | stdin: entries.map((e) => `${e.oid}\n`).join(""), | |
| 97 | timeoutMs: 30_000, | |
| 98 | maxBytes: TEMPLATE_MAX_BYTES + entries.length * 128, | |
| 99 | }); | |
| 100 | if (!raw.ok) return err("template_unreadable"); | |
| 101 | const bytes = raw.value; | |
| 102 | const decoder = new TextDecoder(); | |
| 103 | const files: FileWrite[] = []; | |
| 104 | let at = 0; | |
| 105 | for (const e of entries) { | |
| 106 | const nl = bytes.indexOf(10, at); | |
| 107 | if (nl < 0) return err("template_unreadable"); | |
| 108 | const header = decoder.decode(bytes.subarray(at, nl)).split(" "); | |
| 109 | const size = Number(header[2]); | |
| 110 | if (header[1] !== "blob" || size !== e.size || nl + 1 + size > bytes.length) return err("template_unreadable"); | |
| 111 | files.push({ path: e.path, content: bytes.slice(nl + 1, nl + 1 + size), mode: e.mode as "100644" | "100755" }); | |
| 112 | at = nl + 1 + size + 1; | |
| 113 | } | |
| 114 | return ok(files); | |
| 115 | } |
src/contract/openapi/routes/postApiV1Apps.ts +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import type { Route } from "../shared.ts"; |
| 2 | 2 | |
| 3 | 3 | /** What `POST /api/v1/apps` reads from its body, and the refusals it answers. */ |
| 4 | const APP_BODY = { name: "string", owner: "string", description: "string", sentence: "string", visibility: "string", agent: "string", pick: "boolean" }; | |
| 4 | const APP_BODY = { name: "string", owner: "string", description: "string", sentence: "string", visibility: "string", agent: "string", pick: "boolean", template: "string" }; | |
| 5 | 5 | const APP_REFUSALS = { |
| 6 | 6 | unverified: "the account has not verified its email", |
| 7 | 7 | no_name: "no `name`, and the `sentence` leaves nothing to make one from", |
src/git/env.ts +16 −1
| @@ -245,11 +245,15 @@ export interface RunOptions { | ||
| 245 | 245 | stdin?: Uint8Array | string; |
| 246 | 246 | /** Kill the process after this long. @default 30_000 */ |
| 247 | 247 | timeoutMs?: number; |
| 248 | /** Kill the process, and fail with code `OVER_CAP`, once stdout passes this many bytes. */ | |
| 249 | maxBytes?: number; | |
| 248 | 250 | /** Kill the process when this aborts. */ |
| 249 | 251 | signal?: AbortSignal; |
| 250 | 252 | } |
| 251 | 253 | |
| 252 | 254 | /** Run git to completion, capturing output as bytes. Non-zero exit is an Err. */ |
| 255 | export const OVER_CAP = -2; | |
| 256 | ||
| 253 | 257 | export async function runGitRaw( |
| 254 | 258 | args: readonly string[], |
| 255 | 259 | options: RunOptions = {}, |
| @@ -268,14 +272,19 @@ export async function runGitRaw( | ||
| 268 | 272 | const timeoutMs = options.timeoutMs ?? 30_000; |
| 269 | 273 | const timer = setTimeout(() => proc.kill("SIGKILL"), timeoutMs); |
| 270 | 274 | const stop = () => proc.kill("SIGKILL"); |
| 275 | let over = false; | |
| 271 | 276 | if (options.signal?.aborted) stop(); |
| 272 | 277 | options.signal?.addEventListener("abort", stop, { once: true }); |
| 273 | 278 | try { |
| 274 | 279 | const [stdout, stderr, code] = await Promise.all([ |
| 275 | readAll(proc.stdout), | |
| 280 | readAll(proc.stdout, options.maxBytes, () => { | |
| 281 | over = true; | |
| 282 | stop(); | |
| 283 | }), | |
| 276 | 284 | new Response(proc.stderr).text(), |
| 277 | 285 | proc.exited, |
| 278 | 286 | ]); |
| 287 | if (over) return err({ code: OVER_CAP, stderr: "output over the cap", args }); | |
| 279 | 288 | if (code !== 0) return err({ code, stderr: stderr.trim(), args }); |
| 280 | 289 | return ok(stdout); |
| 281 | 290 | } finally { |
| @@ -407,12 +416,18 @@ export async function runGitCapture( | ||
| 407 | 416 | */ |
| 408 | 417 | async function readAll( |
| 409 | 418 | stream: ReadableStream<Uint8Array>, |
| 419 | maxBytes?: number, | |
| 420 | onOver?: () => void, | |
| 410 | 421 | ): Promise<Uint8Array> { |
| 411 | 422 | const chunks: Uint8Array[] = []; |
| 412 | 423 | let total = 0; |
| 413 | 424 | for await (const chunk of stream) { |
| 414 | 425 | chunks.push(chunk.slice()); |
| 415 | 426 | total += chunk.length; |
| 427 | if (maxBytes !== undefined && total > maxBytes) { | |
| 428 | onOver?.(); | |
| 429 | break; | |
| 430 | } | |
| 416 | 431 | } |
| 417 | 432 | const out = new Uint8Array(total); |
| 418 | 433 | let offset = 0; |
src/http/api.ts +1
| @@ -3581,6 +3581,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3581 | 3581 | ? { visibility: input.visibility } |
| 3582 | 3582 | : {}), |
| 3583 | 3583 | ...(typeof input.agent === "string" ? { agent: input.agent } : {}), |
| 3584 | ...(typeof input.template === "string" && input.template !== "" ? { template: input.template } : {}), | |
| 3584 | 3585 | // Only a real boolean asks; like the other fields, anything else is ignored. |
| 3585 | 3586 | ...(input.pick === true ? { pickAgent: true } : {}), |
| 3586 | 3587 | }; |
src/http/mcp/tools/new_app.ts +6 −3
| @@ -2,7 +2,7 @@ import { appsRuntime } from "../../../apps/runtime.ts"; | ||
| 2 | 2 | import { listActiveRuns } from "../../../core/runs.ts"; |
| 3 | 3 | import { getUserByName } from "../../../core/users.ts"; |
| 4 | 4 | import { repositoryLimitMessage } from "../../../actions/repos.ts"; |
| 5 | import { NO_NAME_ERROR, newApp, newAppFromSentence, type NewAppInput } from "../../../actions/apps.ts"; | |
| 5 | import { NEW_APP_ERRORS, NO_NAME_ERROR, newApp, newAppFromSentence, type NewAppInput } from "../../../actions/apps.ts"; | |
| 6 | 6 | import { appLimitWords } from "../../../actions/app_limit.ts"; |
| 7 | 7 | import { storageLimitMessage } from "../../../core/storage.ts"; |
| 8 | 8 | import { VERIFY_FIRST } from "../../../actions/verify_words.ts"; |
| @@ -19,7 +19,7 @@ async function fromSentence(app: App, user: User, sentence: string, input: Omit< | ||
| 19 | 19 | |
| 20 | 20 | export const tool: Tool = { |
| 21 | 21 | name: "new_app", |
| 22 | doc: "`{ name?, sentence?, description?, owner?, visibility?, agent?, pick? }`: make an app from a sentence: with `sentence` and no `name` the name is derived from it (`-2`, `-3` when taken), as the web and terminal do; `name` and `sentence` together keep the name; the first issue is `description`, else `sentence`; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)", | |
| 22 | doc: "`{ name?, sentence?, description?, owner?, visibility?, agent?, pick?, template? }`: make an app from a sentence: with `sentence` and no `name` the name is derived from it (`-2`, `-3` when taken), as the web and terminal do; `name` and `sentence` together keep the name; the first issue is `description`, else `sentence`; answers `{ repo, url, issue, agent, run }` (`run` is the first run's state, or `none`) and draws the `ui://forge/app` card: name, production URL, first issue, agent, first run; refused as `POST /api/v1/apps` is (unverified, app_limit, no_such_agent, name_taken)", | |
| 23 | 23 | description: |
| 24 | 24 | "Make an app from a sentence: a repo, production, and with `description` a first issue.", |
| 25 | 25 | inputSchema: { |
| @@ -32,6 +32,7 @@ export const tool: Tool = { | ||
| 32 | 32 | visibility: { type: "string", enum: ["public", "private"] }, |
| 33 | 33 | agent: { type: "string", maxLength: 39 }, |
| 34 | 34 | pick: { type: "boolean" }, |
| 35 | template: { type: "string", maxLength: 201, description: "owner/name to copy, readable by you" }, | |
| 35 | 36 | }, |
| 36 | 37 | }, |
| 37 | 38 | writes: true, |
| @@ -42,10 +43,12 @@ export const tool: Tool = { | ||
| 42 | 43 | const description = text("description"); |
| 43 | 44 | const agent = text("agent"); |
| 44 | 45 | const sentence = text("sentence"); |
| 46 | const template = text("template"); | |
| 45 | 47 | const rest: Omit<NewAppInput, "name" | "description"> = { |
| 46 | 48 | ...(owner === undefined ? {} : { owner }), |
| 47 | 49 | ...(visibility === "public" || visibility === "private" ? { visibility } : {}), |
| 48 | 50 | ...(agent === undefined ? {} : { agent }), |
| 51 | ...(template === undefined || template === "" ? {} : { template }), | |
| 49 | 52 | ...(args["pick"] === true ? { pickAgent: true } : {}), |
| 50 | 53 | }; |
| 51 | 54 | const given = str(args["name"]).trim(); |
| @@ -70,7 +73,7 @@ export const tool: Tool = { | ||
| 70 | 73 | case "storage_limit": |
| 71 | 74 | return bad("storage_limit", storageLimitMessage(ctx.app, user.id)); |
| 72 | 75 | default: |
| 73 | return bad(made.error, made.error); | |
| 76 | return bad(made.error, NEW_APP_ERRORS[made.error] ?? made.error); | |
| 74 | 77 | } |
| 75 | 78 | } |
| 76 | 79 | const { repo, app: record, issue } = made.value; |
src/tui/forge.ts +9 −1
| @@ -94,7 +94,7 @@ import { newForgeScreen } from "./screens/newforge.ts"; | ||
| 94 | 94 | import { newOrgScreen } from "./screens/neworg.ts"; |
| 95 | 95 | import { migrateScreen } from "./screens/migrate.ts"; |
| 96 | 96 | import { bringScreen } from "./screens/bring.ts"; |
| 97 | import { newAppScreen } from "./screens/newapp.ts"; | |
| 97 | import { newAppScreen, templateAppScreen } from "./screens/newapp.ts"; | |
| 98 | 98 | import { newRepoScreen } from "./screens/newrepo.ts"; |
| 99 | 99 | import { repoScreen } from "./screens/repo.ts"; |
| 100 | 100 | import { signInScreen } from "./screens/signin.ts"; |
| @@ -288,6 +288,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 288 | 288 | hint: "say what it does in a sentence; the crew starts on it", |
| 289 | 289 | run: () => ctx.push(newAppScreen(session, ctx, blank("New app"))), |
| 290 | 290 | }); |
| 291 | // The same from a template repository, as /apps/new?template= does. | |
| 292 | if (session.user !== undefined && session.user.kind !== "agent") | |
| 293 | commands.push({ | |
| 294 | id: "new-app-template", | |
| 295 | title: "New app from a template", | |
| 296 | hint: "copy a repository you can read, as the starting files", | |
| 297 | run: () => ctx.push(templateAppScreen(session, ctx, blank("New app"))), | |
| 298 | }); | |
| 291 | 299 | // An app from another forge, as /bring does: a person's, never an agent's. |
| 292 | 300 | if (session.user !== undefined && session.user.kind !== "agent") |
| 293 | 301 | commands.push({ |
src/tui/screens/newapp.ts +27 −3
| @@ -13,6 +13,7 @@ import { repositoryLimitMessage } from "../../actions/repos.ts"; | ||
| 13 | 13 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 14 | 14 | import { listOrgsForUser } from "../../core/orgs.ts"; |
| 15 | 15 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 16 | import { parseTemplateName } from "../../apps/template_tip.ts"; | |
| 16 | 17 | import { deriveAppName } from "../../web/pages/apps-name.ts"; |
| 17 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| 18 | 19 | import type { Session } from "../session.ts"; |
| @@ -28,7 +29,11 @@ function refusal(f: Session, code: string, ownerId: string): string { | ||
| 28 | 29 | return NEW_APP_ERRORS[code] ?? "That app could not be created."; |
| 29 | 30 | } |
| 30 | 31 | |
| 31 | export function newAppScreen(f: Session, ctx: Ctx, parent: Screen): Screen { | |
| 32 | /** | |
| 33 | * `template` (`owner/name`) makes the app a copy of that repository's tip, | |
| 34 | * as `/apps/new?template=` does; the action refuses one the person cannot read. | |
| 35 | */ | |
| 36 | export function newAppScreen(f: Session, ctx: Ctx, parent: Screen, template?: string): Screen { | |
| 32 | 37 | const owners = f.user |
| 33 | 38 | ? [f.user.username, ...listOrgsForUser(f.db, f.user.id).map((o) => o.username)] |
| 34 | 39 | : []; |
| @@ -36,7 +41,11 @@ export function newAppScreen(f: Session, ctx: Ctx, parent: Screen): Screen { | ||
| 36 | 41 | const make = (sentence: string, owner: string): void => { |
| 37 | 42 | ctx.toast("Making your app… the crew starts on it next."); |
| 38 | 43 | runAsync(ctx, async () => { |
| 39 | const made = await newAppFromSentence(f.app, f.user, { sentence, owner }); | |
| 44 | const made = await newAppFromSentence(f.app, f.user, { | |
| 45 | sentence, | |
| 46 | owner, | |
| 47 | ...(template === undefined ? {} : { template }), | |
| 48 | }); | |
| 40 | 49 | if (!made.ok) { |
| 41 | 50 | ctx.toast(refusal(f, made.code, f.user!.id), "danger"); |
| 42 | 51 | return; |
| @@ -53,7 +62,7 @@ export function newAppScreen(f: Session, ctx: Ctx, parent: Screen): Screen { | ||
| 53 | 62 | }; |
| 54 | 63 | |
| 55 | 64 | return promptScreen(ctx, parent, { |
| 56 | title: "New app", | |
| 65 | title: template === undefined ? "New app" : `New app from ${template}`, | |
| 57 | 66 | label: "What should it do?", |
| 58 | 67 | placeholder: "a shared shopping list for the flat", |
| 59 | 68 | hint: "One sentence; the app is named from it.", |
| @@ -78,3 +87,18 @@ export function newAppScreen(f: Session, ctx: Ctx, parent: Screen): Screen { | ||
| 78 | 87 | }, |
| 79 | 88 | }); |
| 80 | 89 | } |
| 90 | ||
| 91 | /** Asks which repository to copy, then goes on as "New app" does. */ | |
| 92 | export function templateAppScreen(f: Session, ctx: Ctx, parent: Screen): Screen { | |
| 93 | return promptScreen(ctx, parent, { | |
| 94 | title: "New app from a template", | |
| 95 | label: "Which repository?", | |
| 96 | placeholder: "owner/name", | |
| 97 | hint: "A repository you can read; its files start the app.", | |
| 98 | onSubmit: (named) => { | |
| 99 | const parsed = parseTemplateName(named); | |
| 100 | if (parsed === undefined) return "Name it owner/name."; | |
| 101 | return later(ctx, () => newAppScreen(f, ctx, parent, `${parsed.owner}/${parsed.name}`)); | |
| 102 | }, | |
| 103 | }); | |
| 104 | } |
src/web/pages/apps.ts +8
| @@ -283,6 +283,8 @@ interface NewAppValues { | ||
| 283 | 283 | description: string; |
| 284 | 284 | visibility: string; |
| 285 | 285 | agent: string; |
| 286 | /** `owner/name` of a template to copy; empty for the starter app. */ | |
| 287 | template?: string; | |
| 286 | 288 | } |
| 287 | 289 | |
| 288 | 290 | /** The agent select's sentinel for "turn the sentence into a plan and pick". */ |
| @@ -326,6 +328,9 @@ function newAppForm( | ||
| 326 | 328 | sub: "Describe what it should do. You get a repository, a first issue, a crew member on it, a live app, and a preview for every change.", |
| 327 | 329 | })} |
| 328 | 330 | ${html`<form method="post" action="/apps/new"> |
| 331 | ${values.template | |
Design principles 1 and 8: the page already states its purpose, and this adds a hand-rolled |
||
| 332 | ? formField({ label: "Copy of", name: "template", value: values.template, readonly: true, mono: true }) | |
| 333 | : html``} | |
| 329 | 334 | ${formField({ |
| 330 | 335 | label: "What should it do?", |
| 331 | 336 | name: "description", |
| @@ -449,6 +454,7 @@ export function newAppPage(ctx: Ctx): Response { | ||
| 449 | 454 | description: starterFromQuery(ctx.url.searchParams.get("description")), |
| 450 | 455 | visibility: "private", |
| 451 | 456 | agent: picks ? PICK_AGENT : "", |
| 457 | template: ctx.url.searchParams.get("template") ?? "", | |
| 452 | 458 | }, picks), |
| 453 | 459 | scriptJs: appNameJs, |
| 454 | 460 | }); |
| @@ -469,6 +475,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 469 | 475 | description: field(form, "description"), |
| 470 | 476 | visibility: field(form, "visibility") === "public" ? "public" : "private", |
| 471 | 477 | agent: field(form, "agent"), |
| 478 | template: field(form, "template"), | |
| 472 | 479 | }; |
| 473 | 480 | const pickAgent = values.agent === PICK_AGENT; |
| 474 | 481 | // An explicit name is never second-guessed; left blank, the sentence |
| @@ -478,6 +485,7 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 478 | 485 | const rest = { |
| 479 | 486 | owner: values.owner, |
| 480 | 487 | visibility: values.visibility === "public" ? "public" : "private", |
| 488 | ...(values.template ? { template: values.template } : {}), | |
| 481 | 489 | ...(pickAgent |
| 482 | 490 | ? { pickAgent: true } |
| 483 | 491 | : values.agent === "" |
test/actions-newapp-template.test.ts added +180
| @@ -0,0 +1,180 @@ | ||
| 1 | /** | |
| 2 | * `newApp` with a `template`: the tip of a repository the caller can read is | |
| 3 | * copied; an unreadable, oversized or symlinked template is refused before a | |
| 4 | * repository exists. | |
| 5 | */ | |
| 6 | import { afterAll, expect, setDefaultTimeout, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { NEW_APP_ERRORS, newApp } from "../src/actions/apps.ts"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { setRole } from "../src/actions/roles.ts"; | |
| 13 | import { TEMPLATE_ERRORS, TEMPLATE_MAX_BYTES, TEMPLATE_MAX_FILES } from "../src/apps/template_tip.ts"; | |
| 14 | import { createApp } from "../src/app.ts"; | |
| 15 | import { getRepo } from "../src/core/repos.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { repoDir } from "../src/git/repos.ts"; | |
| 18 | import { readerFor } from "../src/git/read.ts"; | |
| 19 | import type { FileWrite } from "../src/git/write.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | setDefaultTimeout(30_000); | |
| 24 | const dirs: string[] = []; | |
| 25 | afterAll(async () => { | |
| 26 | for (const d of dirs) await rm(d, { recursive: true, force: true }); | |
| 27 | }); | |
| 28 | ||
| 29 | async function world() { | |
| 30 | const dir = await mkdtemp(join(tmpdir(), "forge-tpl-")); | |
| 31 | dirs.push(dir); | |
| 32 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 33 | const mk = async (username: string, isAdmin = false) => { | |
| 34 | const made = await createUser(app.db, { | |
| 35 | origin: "seed", trusted: true, username, email: `${username}@x.test`, isAdmin, | |
| 36 | }); | |
| 37 | if (!made.ok) throw new Error(made.error); | |
| 38 | return made.value; | |
| 39 | }; | |
| 40 | const admin = await mk("boss", true); | |
| 41 | const owner = await mk("giver"); | |
| 42 | const taker = await mk("taker"); | |
| 43 | for (const u of [owner, taker]) setRole(app, admin, u.username, "member"); | |
| 44 | const template = async ( | |
| 45 | name: string, | |
| 46 | visibility: "public" | "private", | |
| 47 | files: FileWrite[], | |
| 48 | ) => { | |
| 49 | const repo = await createRepo(app, owner, { name, visibility, description: "" }); | |
| 50 | if (!repo.ok) throw new Error(repo.error); | |
| 51 | const done = await commitToBranch(app, owner, repo.value, { message: "t", files }); | |
| 52 | if (!done.ok) throw new Error(String(done.error)); | |
| 53 | }; | |
| 54 | return { app, owner, taker, template }; | |
| 55 | } | |
| 56 | ||
| 57 | test("a public template's tip is copied, modes kept", async () => { | |
| 58 | const { app, taker, template } = await world(); | |
| 59 | await template("kit", "public", [ | |
| 60 | { path: "README.md", content: "# kit\n" }, | |
| 61 | { path: "bin/run.sh", content: "echo hi\n", mode: "100755" }, | |
| 62 | ]); | |
| 63 | const made = await newApp(app, taker, { name: "mine", template: "giver/kit" }); | |
| 64 | expect(made.ok).toBe(true); | |
| 65 | if (!made.ok) return; | |
| 66 | const dir = repoDir(app.reposRoot, "taker", "mine"); | |
| 67 | const readme = await readerFor(dir).object(`refs/heads/${made.value.repo.defaultBranch}:README.md`); | |
| 68 | expect(readme.ok && new TextDecoder().decode(readme.value.body)).toBe("# kit\n"); | |
| 69 | const tree = Bun.spawnSync(["git", "ls-tree", "-r", `refs/heads/${made.value.repo.defaultBranch}`], { cwd: dir }).stdout.toString(); | |
| 70 | expect(tree).toContain("100755 blob"); | |
| 71 | expect(tree).toMatch(/^100755 blob \w+\tbin\/run\.sh$/m); | |
| 72 | expect(tree).toMatch(/^100644 blob \w+\tREADME\.md$/m); | |
| 73 | // Not the starter app. | |
| 74 | const starter = await readerFor(dir).stat(`refs/heads/${made.value.repo.defaultBranch}:src/server.ts`); | |
| 75 | expect(starter.ok).toBe(false); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a private template the caller cannot read answers as one that does not exist, and nothing is made", async () => { | |
| 79 | const { app, taker, template } = await world(); | |
| 80 | await template("secret", "private", [{ path: "a.txt", content: "x" }]); | |
| 81 | const hidden = await newApp(app, taker, { name: "mine", template: "giver/secret" }); | |
| 82 | const absent = await newApp(app, taker, { name: "mine", template: "giver/nothing" }); | |
| 83 | expect(hidden).toEqual({ ok: false, error: "no_such_template" }); | |
| 84 | expect(absent).toEqual(hidden); | |
| 85 | expect(getRepo(app.db, "taker", "mine")).toBeUndefined(); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a malformed template name is refused", async () => { | |
| 89 | const { app, taker } = await world(); | |
| 90 | for (const template of ["x", "../a/b", "a/b/c", "a/b;rm"]) { | |
| 91 | const made = await newApp(app, taker, { name: "mine", template }); | |
| 92 | expect(made).toEqual({ ok: false, error: "no_such_template" }); | |
| 93 | } | |
| 94 | }); | |
| 95 | ||
| 96 | test("a template over the file cap is refused", async () => { | |
| 97 | const { app, taker, template } = await world(); | |
| 98 | const files: FileWrite[] = Array.from({ length: TEMPLATE_MAX_FILES + 1 }, (_, i) => ({ | |
| 99 | path: `f/${i}.txt`, content: "x", | |
| 100 | })); | |
| 101 | await template("huge", "public", files); | |
| 102 | const made = await newApp(app, taker, { name: "mine", template: "giver/huge" }); | |
| 103 | expect(made).toEqual({ ok: false, error: "template_too_big" }); | |
| 104 | expect(getRepo(app.db, "taker", "mine")).toBeUndefined(); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a template file over the byte cap is refused", async () => { | |
| 108 | const { app, taker, template } = await world(); | |
| 109 | await template("fat", "public", [{ path: "big.bin", content: new Uint8Array(600 * 1024) }]); | |
| 110 | const made = await newApp(app, taker, { name: "mine", template: "giver/fat" }); | |
| 111 | expect(made).toEqual({ ok: false, error: "template_too_big" }); | |
| 112 | }); | |
| 113 | ||
| 114 | /** Commit one more tree entry of `mode` onto a template's default branch. */ | |
| 115 | async function addEntry(app: ReturnType<typeof createApp>, repoName: string, mode: string, oid: string, path: string) { | |
| 116 | const dir = repoDir(app.reposRoot, "giver", repoName); | |
| 117 | const git = (args: string[], env: Record<string, string> = {}, input?: string) => { | |
| 118 | const r = Bun.spawnSync(["git", ...args], { | |
| 119 | cwd: dir, stdin: input === undefined ? "ignore" : new Blob([input]), | |
| 120 | env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@x", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@x", ...env }, | |
| 121 | }); | |
| 122 | return r.stdout.toString().trim(); | |
| 123 | }; | |
| 124 | const branch = getRepo(app.db, "giver", repoName)!.defaultBranch; | |
| 125 | const index = join(dir, "tmp-index"); | |
| 126 | const env = { GIT_INDEX_FILE: index }; | |
| 127 | git(["read-tree", `refs/heads/${branch}`], env); | |
| 128 | git(["update-index", "--add", "--cacheinfo", `${mode},${oid},${path}`], env); | |
| 129 | const tree = git(["write-tree"], env); | |
| 130 | const commit = git(["commit-tree", tree, "-p", `refs/heads/${branch}`, "-m", "entry"]); | |
| 131 | git(["update-ref", `refs/heads/${branch}`, commit]); | |
| 132 | } | |
| 133 | ||
| 134 | test("a template holding a symlink is refused", async () => { | |
outdated
Add tests for the missing cases listed in the review body. Assert the mode of |
||
| 135 | const { app, taker, template } = await world(); | |
| 136 | await template("linky", "public", [{ path: "a.txt", content: "x" }]); | |
| 137 | const dir = repoDir(app.reposRoot, "giver", "linky"); | |
| 138 | const target = Bun.spawnSync(["git", "hash-object", "-w", "--stdin"], { cwd: dir, stdin: new Blob(["a.txt"]) }).stdout.toString().trim(); | |
| 139 | await addEntry(app, "linky", "120000", target, "link"); | |
| 140 | const made = await newApp(app, taker, { name: "mine", template: "giver/linky" }); | |
| 141 | expect(made).toEqual({ ok: false, error: "template_has_symlink" }); | |
| 142 | expect(getRepo(app.db, "taker", "mine")).toBeUndefined(); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a template holding a submodule is refused as unsupported", async () => { | |
| 146 | const { app, taker, template } = await world(); | |
| 147 | await template("modular", "public", [{ path: "a.txt", content: "x" }]); | |
| 148 | await addEntry(app, "modular", "160000", "1".repeat(40), "vendor"); | |
| 149 | const made = await newApp(app, taker, { name: "mine", template: "giver/modular" }); | |
| 150 | expect(made).toEqual({ ok: false, error: "template_unsupported" }); | |
| 151 | expect(getRepo(app.db, "taker", "mine")).toBeUndefined(); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a template over the total byte cap is refused though every file is under its own", async () => { | |
| 155 | const { app, taker, template } = await world(); | |
| 156 | const each = 500 * 1024; | |
| 157 | const files: FileWrite[] = Array.from({ length: Math.ceil(TEMPLATE_MAX_BYTES / each) + 1 }, (_, i) => ({ | |
| 158 | path: `blob${i}.bin`, content: `${i}`.repeat(each).slice(0, each), | |
| 159 | })); | |
| 160 | await template("heavy", "public", files); | |
| 161 | const made = await newApp(app, taker, { name: "mine", template: "giver/heavy" }); | |
| 162 | expect(made).toEqual({ ok: false, error: "template_too_big" }); | |
| 163 | expect(getRepo(app.db, "taker", "mine")).toBeUndefined(); | |
| 164 | }); | |
| 165 | ||
| 166 | test("a template with no commits is refused as empty", async () => { | |
| 167 | const { app, owner, taker } = await world(); | |
| 168 | const repo = await createRepo(app, owner, { name: "bare", visibility: "public", description: "" }); | |
| 169 | expect(repo.ok).toBe(true); | |
| 170 | const made = await newApp(app, taker, { name: "mine", template: "giver/bare" }); | |
| 171 | expect(made).toEqual({ ok: false, error: "template_empty" }); | |
| 172 | expect(getRepo(app.db, "taker", "mine")).toBeUndefined(); | |
| 173 | }); | |
| 174 | ||
| 175 | test("every refusal has words, and the too-big words name all three caps", () => { | |
| 176 | for (const code of Object.keys(TEMPLATE_ERRORS)) expect(NEW_APP_ERRORS[code]).toBeTruthy(); | |
| 177 | expect(TEMPLATE_ERRORS.template_too_big).toContain(`${TEMPLATE_MAX_FILES} files`); | |
| 178 | expect(TEMPLATE_ERRORS.template_too_big).toContain("512 KiB a file"); | |
| 179 | expect(TEMPLATE_ERRORS.template_too_big).toContain("4 MiB in all"); | |
| 180 | }); |
test/apps-template-doors.test.ts added +196
| @@ -0,0 +1,196 @@ | ||
| 1 | /** | |
| 2 | * Every door that can name a template: the page (`/apps/new?template=`), the | |
| 3 | * API field, the MCP tool and the terminal's palette entry. Each copies the | |
| 4 | * tip of a readable repository and refuses one that is not, in the words of | |
| 5 | * `TEMPLATE_ERRORS`. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, 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 { TEMPLATE_ERRORS } from "../src/apps/template_tip.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { getRepo } from "../src/core/repos.ts"; | |
| 16 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 19 | import { blank } from "../src/tui/screens/common.ts"; | |
| 20 | import { templateAppScreen } from "../src/tui/screens/newapp.ts"; | |
| 21 | import { deriveAppName } from "../src/web/pages/apps-name.ts"; | |
| 22 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | import { settleApp } from "./helpers/tui.ts"; | |
| 25 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | setDefaultTimeout(30_000); | |
| 29 | ||
| 30 | let dir: string; | |
| 31 | let app: App; | |
| 32 | let servers: Servers; | |
| 33 | let base: string; | |
| 34 | let giver: User; | |
| 35 | let cookie: string; | |
| 36 | let token: string; | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dir = await mkdtemp(join(tmpdir(), "forge-tpl-doors-")); | |
| 40 | app = createApp({ dataDir: dir, port: 0 }); | |
| 41 | servers = await serve(app); | |
| 42 | base = servers.url; | |
| 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 | giver = await mk("giver", true); | |
| 52 | const taker = await mk("taker"); | |
| 53 | setRole(app, giver, taker.username, "member"); | |
| 54 | for (const [name, visibility] of [["kit", "public"], ["secret", "private"]] as const) { | |
| 55 | const repo = await createRepo(app, giver, { name, visibility, description: "" }); | |
| 56 | if (!repo.ok) throw new Error(repo.error); | |
| 57 | const done = await commitToBranch(app, giver, repo.value, { | |
| 58 | message: "t", | |
| 59 | files: [{ path: "README.md", content: `# ${name}\n` }], | |
| 60 | }); | |
| 61 | if (!done.ok) throw new Error(String(done.error)); | |
| 62 | } | |
| 63 | cookie = ( | |
| 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 | token = tokenFor(app, "taker", "rw"); | |
| 73 | }); | |
| 74 | afterAll(async () => { | |
| 75 | servers.stop(); | |
| 76 | app.close(); | |
| 77 | await rm(dir, { recursive: true, force: true }); | |
| 78 | }); | |
| 79 | ||
| 80 | const readme = (owner: string, name: string): string => { | |
| 81 | const repo = getRepo(app.db, owner, name)!; | |
| 82 | return Bun.spawnSync(["git", "show", `refs/heads/${repo.defaultBranch}:README.md`], { | |
| 83 | cwd: join(app.reposRoot, owner, `${name}.git`), | |
| 84 | }).stdout.toString(); | |
| 85 | }; | |
| 86 | ||
| 87 | test("the page carries ?template= as a read-only field, escaped, and the POST makes the copy", async () => { | |
| 88 | const shown = await (await fetch(`${base}/apps/new?template=giver/kit`, { headers: { cookie } })).text(); | |
| 89 | expect(shown).toMatch(/<input[^>]*name="template"[^>]*readonly[^>]*value="giver\/kit"|<input[^>]*name="template"[^>]*value="giver\/kit"[^>]*readonly/s); | |
| 90 | const hostile = await (await fetch(`${base}/apps/new?template=${encodeURIComponent('"><script>x</script>')}`, { headers: { cookie } })).text(); | |
| 91 | expect(hostile).not.toContain("<script>x</script>"); | |
| 92 | expect(hostile).toContain("<script>"); | |
| 93 | const plain = await (await fetch(`${base}/apps/new`, { headers: { cookie } })).text(); | |
| 94 | expect(plain).not.toContain('name="template"'); | |
| 95 | ||
| 96 | const posted = await fetch(`${base}/apps/new`, { | |
| 97 | method: "POST", | |
| 98 | headers: { cookie, origin: base }, | |
| 99 | body: new URLSearchParams({ | |
| 100 | owner: "", name: "from-page", description: "x", visibility: "private", agent: "", template: "giver/kit", | |
| 101 | }), | |
| 102 | redirect: "manual", | |
| 103 | }); | |
| 104 | expect(posted.status).toBe(303); | |
| 105 | expect(readme("taker", "from-page")).toBe("# kit\n"); | |
| 106 | }); | |
| 107 | ||
| 108 | test("the page refuses a template it cannot read, in words, and makes nothing", async () => { | |
| 109 | const posted = await fetch(`${base}/apps/new`, { | |
| 110 | method: "POST", | |
| 111 | headers: { cookie, origin: base }, | |
| 112 | body: new URLSearchParams({ | |
| 113 | owner: "", name: "from-page-2", description: "x", visibility: "private", agent: "", template: "giver/secret", | |
| 114 | }), | |
| 115 | redirect: "manual", | |
| 116 | }); | |
| 117 | expect(getRepo(app.db, "taker", "from-page-2")).toBeUndefined(); | |
| 118 | const body = posted.status === 303 ? "" : await posted.text(); | |
| 119 | expect(posted.status === 303 ? posted.headers.get("location") : body).not.toBe("/taker/from-page-2"); | |
| 120 | if (body) expect(body).toContain(TEMPLATE_ERRORS.no_such_template); | |
| 121 | }); | |
| 122 | ||
| 123 | test("the API field copies a readable template and refuses a private one as missing", async () => { | |
| 124 | const api = (body: unknown) => | |
| 125 | fetch(`${base}/api/v1/apps`, { | |
| 126 | method: "POST", | |
| 127 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 128 | body: JSON.stringify(body), | |
| 129 | }); | |
| 130 | const ok = await api({ name: "from-api", description: "x", template: "giver/kit" }); | |
| 131 | expect(ok.status).toBe(201); | |
| 132 | expect(readme("taker", "from-api")).toBe("# kit\n"); | |
| 133 | const hidden = await api({ name: "from-api-2", description: "x", template: "giver/secret" }); | |
| 134 | const absent = await api({ name: "from-api-2", description: "x", template: "giver/none" }); | |
| 135 | expect(hidden.status).toBeGreaterThanOrEqual(400); | |
| 136 | expect(hidden.status).toBe(absent.status); | |
| 137 | expect(await hidden.json()).toEqual(await absent.json()); | |
| 138 | expect(getRepo(app.db, "taker", "from-api-2")).toBeUndefined(); | |
| 139 | }); | |
| 140 | ||
| 141 | test("the MCP tool copies the template and maps a refusal to its words", async () => { | |
| 142 | const call = async (args: unknown) => { | |
| 143 | const res = await fetch(`${base}/mcp`, { | |
| 144 | method: "POST", | |
| 145 | headers: { | |
| 146 | "content-type": "application/json", | |
| 147 | accept: "application/json, text/event-stream", | |
| 148 | authorization: `Bearer ${token}`, | |
| 149 | }, | |
| 150 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "new_app", arguments: args } }), | |
| 151 | }); | |
| 152 | const body = (await res.json()) as any; | |
| 153 | return { error: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 154 | }; | |
| 155 | const made = await call({ name: "from-mcp", description: "x", template: "giver/kit" }); | |
| 156 | expect(made.error).toBe(false); | |
| 157 | expect(readme("taker", "from-mcp")).toBe("# kit\n"); | |
| 158 | const refused = await call({ name: "from-mcp-2", description: "x", template: "giver/secret" }); | |
| 159 | expect(refused.error).toBe(true); | |
| 160 | expect(refused.text).toContain(TEMPLATE_ERRORS.no_such_template); | |
| 161 | expect(getRepo(app.db, "taker", "from-mcp-2")).toBeUndefined(); | |
| 162 | }); | |
| 163 | ||
| 164 | test("the terminal offers the template entry to a person, not an agent or a guest, and makes the copy", async () => { | |
| 165 | const identity = (user?: User) => ({ | |
| 166 | sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user, | |
| 167 | }); | |
| 168 | const taker = getUserByName(app.db, "taker")!; | |
| 169 | const ids = (u?: User) => forgeCommands(new Session(app, identity(u)))({} as never).map((c) => c.id); | |
| 170 | expect(ids(taker)).toContain("new-app-template"); | |
| 171 | expect(ids({ ...taker, kind: "agent" })).not.toContain("new-app-template"); | |
| 172 | expect(ids()).not.toContain("new-app-template"); | |
| 173 | ||
| 174 | const terminal = headlessTerminal(120, 30); | |
| 175 | const { app: tui, session } = createForgeApp(app, identity(taker), { terminal, exitHooks: false }); | |
| 176 | tui.start(); | |
| 177 | tui.ctx.push(templateAppScreen(session, tui.ctx, blank("New app"))); | |
| 178 | try { | |
| 179 | const feed = async (s: string) => { | |
| 180 | terminal.feed(s); | |
| 181 | await settleApp(tui); | |
| 182 | }; | |
| 183 | await settleApp(tui); | |
| 184 | await feed("nonsense\r"); | |
| 185 | expect(tui.screen.toText()).toContain("Name it owner/name."); | |
| 186 | for (let i = 0; i < "nonsense".length; i++) await feed("\x7f"); | |
| 187 | await feed("giver/kit\r"); | |
| 188 | await feed("a copy from the terminal\r"); | |
| 189 | const named = deriveAppName("a copy from the terminal")!; | |
| 190 | const deadline = Date.now() + 8000; | |
| 191 | while (getRepo(app.db, "taker", named) === undefined && Date.now() < deadline) await Bun.sleep(20); | |
| 192 | expect(readme("taker", named)).toBe("# kit\n"); | |
| 193 | } finally { | |
| 194 | tui.stop(); | |
| 195 | } | |
| 196 | }); |
test/palette-roster.test.ts +2
| @@ -73,6 +73,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 73 | 73 | ["settings", "Repository settings", "owner/name"], |
| 74 | 74 | ["new-repo", "New repository", undefined], |
| 75 | 75 | ["new-app", "New app", "say what it does in a sentence; the crew starts on it"], |
| 76 | ["new-app-template", "New app from a template", "copy a repository you can read, as the starting files"], | |
| 76 | 77 | ["bring-app", "Bring an app", "paste a link from another forge; it runs here"], |
| 77 | 78 | ["new-org", "New organization", "an organization you own, for a client's apps"], |
| 78 | 79 | ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"], |
| @@ -268,6 +269,7 @@ test("a member person is offered the signed-in roster without the admin entries" | ||
| 268 | 269 | ["settings", "Repository settings", "owner/name"], |
| 269 | 270 | ["new-repo", "New repository", undefined], |
| 270 | 271 | ["new-app", "New app", "say what it does in a sentence; the crew starts on it"], |
| 272 | ["new-app-template", "New app from a template", "copy a repository you can read, as the starting files"], | |
| 271 | 273 | ["bring-app", "Bring an app", "paste a link from another forge; it runs here"], |
| 272 | 274 | ["new-org", "New organization", "an organization you own, for a client's apps"], |
| 273 | 275 | ["migrate", "Migrate a repository", "bring one in from an origin or a URL, pick its branch, put a crew on it"], |
The
template_too_bigmessage omits the 512 KiB per-file cap, so a person hitting that cap is told something that does not match. Name all three caps.