Public: anyone can read this; the crew can change it.
The app page's Adapter section has Pull now, as the API and MCP already do #10144
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10125-the-app-page-s-adapter-section-has-pull → main 5 commits fork 2 threads · 2 unresolved
5 files
changed +172
−11
docs/features/adapters-card.md +7
| @@ -12,6 +12,13 @@ when), the **next pull** and the **cursor** of the last good pull. | ||
| 12 | 12 | rows. A writer presses `p` there to pull now (`pullNow`): the rows are |
| 13 | 13 | read again, and a refusal shows its `PULL_NOW_REFUSALS` sentence in the |
| 14 | 14 | status line. Anyone else is shown no key and `p` does nothing. |
| 15 | - **Pull now:** a writer (not a reader) also gets a **Pull now** button | |
| 16 | under the rows. It posts same-origin to `POST /<owner>/<repo>/app/pull` | |
| 17 | (`adapterPull`, `src/web/studio/adapter.ts`), which calls `pullNow`, the | |
| 18 | function the API and MCP use, and redirects back to the app page, where | |
| 19 | Last pull shows the new run. A refusal comes back as `?pull=<code>` and | |
| 20 | shows its sentence from `PULL_NOW_REFUSALS` in one muted line. Anyone who | |
| 21 | may not write gets 404 and no button. | |
| 15 | 22 | - Both list the **last ten pull runs** under the facts (`pullRunLines`, |
| 16 | 23 | limit 10): when, ok or failed, written/skipped/failed and, for a failed |
| 17 | 24 | run, its error sentence. With no runs, one muted line says "No pull runs |
src/http/server.ts +2
| @@ -219,6 +219,7 @@ import { | ||
| 219 | 219 | newIssueAnywhere, |
| 220 | 220 | newIssueSubmit, |
| 221 | 221 | } from "../web/pages/issues.ts"; |
| 222 | import { adapterPull } from "../web/studio/adapter.ts"; | |
| 222 | 223 | import { noteModerate } from "../web/studio/notes.ts"; |
| 223 | 224 | import { tellPage } from "../web/studio/tell.ts"; |
| 224 | 225 | import { issuePicture } from "../web/studio/picture.ts"; |
| @@ -1288,6 +1289,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1288 | 1289 | // Where a private app sends a person who arrived by its link. |
| 1289 | 1290 | router.add("GET", "/apps/open", appsOpen); |
| 1290 | 1291 | router.add("POST", "/:owner/:name/app/notes", noteModerate); |
| 1292 | router.add("POST", "/:owner/:name/app/pull", adapterPull); | |
| 1291 | 1293 | router.add("POST", "/:owner/:name/app/:env/redeploy", appRedeploy); |
| 1292 | 1294 | router.add("POST", "/:owner/:name/app/:env/stop", appStop); |
| 1293 | 1295 | router.add("GET", "/:owner/:name/events", eventsList); |
src/web/studio/adapter.ts +52 −10
| @@ -8,11 +8,24 @@ | ||
| 8 | 8 | * `adapterFactRows`, shared with the terminal; this module adds only markup. |
| 9 | 9 | */ |
| 10 | 10 | import type { PullRunLine } from "../../apps/pull.ts"; |
| 11 | import type { App } from "../../app.ts"; | |
| 11 | import type { Repo } from "../../core/repos.ts"; | |
| 12 | 12 | import type { AppRecord } from "../../core/apps.ts"; |
| 13 | 13 | import { NO_RUNS_WORDS, adapterFactRows, pullRunWords, adapterFactsOf, everyWords, type AdapterFacts, type FactPart } from "../../apps/adapter_facts.ts"; |
| 14 | import { pullNow, PULL_NOW_REFUSALS, type PullNowError } from "../../actions/app_pull.ts"; | |
| 15 | import { requireWriter } from "../../actions/access.ts"; | |
| 16 | import { getAppByRepo } from "../../core/apps.ts"; | |
| 17 | import type { Ctx } from "../../http/context.ts"; | |
| 18 | import { loadRepo, notFoundPage, repoBase } from "../chrome.ts"; | |
| 19 | import { readForm } from "../form.ts"; | |
| 14 | 20 | import { html, type Html } from "../html.ts"; |
| 15 | import { badge, time } from "../ui.ts"; | |
| 21 | import { redirect } from "../layout.ts"; | |
| 22 | import { badge, btn, time } from "../ui.ts"; | |
| 23 | ||
| 24 | /** What a writer is offered under the rows: the form's address, and the sentence of a refused pull, if the last one was. */ | |
| 25 | export interface PullOffer { | |
| 26 | readonly action: string; | |
| 27 | readonly refusal?: string | undefined; | |
| 28 | } | |
| 16 | 29 | |
| 17 | 30 | export { everyWords, type AdapterFacts }; |
| 18 | 31 | |
| @@ -32,17 +45,52 @@ function part(p: FactPart): Html { | ||
| 32 | 45 | } |
| 33 | 46 | |
| 34 | 47 | /** The adapter's facts as plain dl rows under an h2 (principles 6 and 7: a fact is a row, not a card). */ |
| 35 | export function adapterCardView(facts: AdapterFacts, now: number = Date.now()): Html { | |
| 48 | export function adapterCardView(facts: AdapterFacts, now: number = Date.now(), pull?: PullOffer): Html { | |
| 36 | 49 | return html`<section class="adapter"> |
| 37 | 50 | <h2>Adapter</h2> |
| 38 | 51 | <dl> |
| 39 | 52 | ${adapterFactRows(facts, now).map((row) => html`<dt>${row.label}</dt> |
| 40 | 53 | <dd>${row.parts.map(part)}</dd>`)} |
| 41 | </dl> | |
| 54 | </dl>${pull === undefined | |
| 55 | ? null | |
| 56 | : html` | |
| 57 | <form method="post" action="${pull.action}">${btn("Pull now", { size: "sm" })}</form>${pull.refusal === undefined | |
| 58 | ? null | |
| 59 | : html` | |
| 60 | <p class="muted small">${pull.refusal}</p>`}`} | |
| 42 | 61 | ${runsList(facts.runs ?? [])} |
| 43 | 62 | </section>`; |
| 44 | 63 | } |
| 45 | 64 | |
| 65 | /** The refusal a `?pull=` code names, or none: only the codes `pullNow` can refuse with are believed. */ | |
| 66 | export function pullRefusalOf(code: string | null): string | undefined { | |
| 67 | return code !== null && Object.hasOwn(PULL_NOW_REFUSALS, code) ? PULL_NOW_REFUSALS[code as PullNowError] : undefined; | |
| 68 | } | |
| 69 | ||
| 70 | /** The section for an app that declares an adapter on its default branch; null for any other. A writer is offered Pull now. */ | |
| 71 | export async function adapterCard(ctx: Ctx, record: AppRecord, repo: Repo): Promise<Html | null> { | |
| 72 | const facts = await adapterFactsOf(ctx.app, record); | |
| 73 | if (facts === undefined) return null; | |
| 74 | const base = repoBase(repo); | |
| 75 | const writer = requireWriter(ctx.app, ctx.auth.user, repo).ok; | |
| 76 | return adapterCardView( | |
| 77 | facts, | |
| 78 | Date.now(), | |
| 79 | writer ? { action: `${base}/app/pull`, refusal: pullRefusalOf(ctx.url.searchParams.get("pull")) } : undefined, | |
| 80 | ); | |
| 81 | } | |
| 82 | ||
| 83 | // POST /:owner/:name/app/pull — pull now, then back to the app page; a refusal rides as `?pull=<code>`. | |
| 84 | export async function adapterPull(ctx: Ctx): Promise<Response> { | |
| 85 | const repo = loadRepo(ctx); | |
| 86 | if (!repo || !requireWriter(ctx.app, ctx.auth.user, repo).ok || !getAppByRepo(ctx.app.db, repo.id)) return notFoundPage(ctx); | |
| 87 | const form = await readForm(ctx, "rw"); | |
| 88 | if (form instanceof Response) return form; | |
| 89 | const pulled = await pullNow(ctx.app, ctx.auth.user, repo); | |
| 90 | const back = repoBase(repo); // /app answers 302 to here; the query would be lost | |
| 91 | return redirect(pulled.ok ? back : `${back}?pull=${pulled.error}`, { url: ctx.url, forgeUrl: ctx.app.config.url }); | |
| 92 | } | |
| 93 | ||
| 46 | 94 | /** Whether the sync is healthy over time: the heading is always there, the runs or one muted line under it. */ |
| 47 | 95 | function runsList(runs: readonly PullRunLine[]): Html { |
| 48 | 96 | return html`<h3>Pull runs</h3> |
| @@ -52,9 +100,3 @@ function runsList(runs: readonly PullRunLine[]): Html { | ||
| 52 | 100 | ${runs.map((r) => html`<li class="break">${time(r.at)} ${badge(r.state, { variant: "outline" })} ${pullRunWords(r)}</li>`)} |
| 53 | 101 | </ul>`}`; |
| 54 | 102 | } |
| 55 | ||
| 56 | /** The section for an app that declares an adapter on its default branch; null for any other. */ | |
| 57 | export async function adapterCard(app: App, record: AppRecord): Promise<Html | null> { | |
| 58 | const facts = await adapterFactsOf(app, record); | |
| 59 | return facts === undefined ? null : adapterCardView(facts); | |
| 60 | } |
src/web/studio/app.ts +1 −1
| @@ -493,7 +493,7 @@ export async function studioApp( | ||
| 493 | 493 | policyLine(ctx, repo, record), |
| 494 | 494 | ); |
| 495 | 495 | const adapter = ownerFacts |
| 496 | ? await timePhaseAsync(req, "adapter", () => adapterCard(ctx.app, record)) | |
| 496 | ? await timePhaseAsync(req, "adapter", () => adapterCard(ctx, record, repo)) | |
| 497 | 497 | : null; |
| 498 | 498 | const rendering = performance.now(); |
| 499 | 499 | const body = html`${repoHeader(ctx, repo, "app", { |
test/adapter-pull-button.test.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** The app page's Pull now button (#10125): a writer's same-origin POST runs `pullNow`; anyone else gets 404; cross-origin is refused. */ | |
| 2 | import { afterAll, beforeAll, expect, mock, spyOn, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { newApp } from "../src/actions/apps.ts"; | |
| 8 | import { PULL_NOW_REFUSALS } from "../src/actions/app_pull.ts"; | |
| 9 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 10 | import { repoDir } from "../src/git/repos.ts"; | |
| 11 | import { commitFiles } from "../src/git/write.ts"; | |
| 12 | import { getRepo } from "../src/core/repos.ts"; | |
| 13 | import { listPullRuns } from "../src/core/runs.ts"; | |
| 14 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 17 | import { promote } from "./helpers/members.ts"; | |
| 18 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 19 | ||
| 20 | let dataDir: string, app: App, servers: Servers, runtime: AppsRuntime, adapterApp: ReturnType<typeof Bun.serve>; | |
| 21 | let appId: string, aliceRw: string, bobRw: string, awake = true; | |
| 22 | ||
| 23 | const send = (token: string, origin: string | undefined) => | |
| 24 | fetch(`${servers.url}/alice/hello/app/pull`, { | |
| 25 | method: "POST", | |
| 26 | redirect: "manual", | |
| 27 | headers: { authorization: `Bearer ${token}`, ...(origin ? { origin } : {}) }, | |
| 28 | }); | |
| 29 | const page = (token: string, qs = "") => | |
| 30 | fetch(`${servers.url}/alice/hello${qs}`, { headers: { authorization: `Bearer ${token}` } }).then((r) => r.text()); | |
| 31 | ||
| 32 | beforeAll(async () => { | |
| 33 | dataDir = await mkdtemp(join(tmpdir(), "forge-adapter-pull-button-")); | |
| 34 | app = createApp({ dataDir, port: 0 }); | |
| 35 | servers = await serve(app); | |
| 36 | await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" }); | |
| 37 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 38 | if (!b.ok) throw new Error(b.error); | |
| 39 | promote(app.db, b.value); | |
| 40 | const made = await newApp(app, getUserByName(app.db, "alice")!, { name: "hello", visibility: "public" } as never); | |
| 41 | if (!made.ok) throw new Error(String(made.error)); | |
| 42 | const repo = getRepo(app.db, "alice", "hello")!; | |
| 43 | appId = (app.db.query("SELECT id FROM app WHERE repo_id = ?1").get(repo.id) as { id: string }).id; | |
| 44 | aliceRw = tokenFor(app, "alice", "rw"); | |
| 45 | bobRw = tokenFor(app, "bob", "rw"); | |
| 46 | adapterApp = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => Response.json({ pulled: 2, skipped: 1, cursor: "r1", errors: 0 }) }); | |
| 47 | runtime = new AppsRuntime(app, { domain: "pull.test", scheme: "http", publicPort: servers.port, forgeUrl: servers.url }); | |
| 48 | spyOn(AppsRuntime.prototype, "envStatus").mockImplementation( | |
| 49 | (): EnvStatus => | |
| 50 | (awake | |
| 51 | ? { env: "production", state: "running", commitOid: "0".repeat(40), url: "", port: adapterApp.port } | |
| 52 | : { env: "production", state: "stopped", commitOid: "0".repeat(40), url: "" }) as EnvStatus, | |
| 53 | ); | |
| 54 | const c = await commitFiles(repoDir(app.reposRoot, "alice", "hello"), { | |
| 55 | branch: repo.defaultBranch, | |
| 56 | message: "forge.json", | |
| 57 | files: [{ path: "forge.json", content: JSON.stringify({ run: "bun run src/server.ts", adapter: { schedule: { every: 3600 } } }) }], | |
| 58 | author: { name: "T", email: "[email protected]" }, | |
| 59 | }); | |
| 60 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 61 | }, 30_000); | |
| 62 | ||
| 63 | afterAll(async () => { | |
| 64 | mock.restore(); | |
| 65 | await runtime.stop(); | |
| 66 | adapterApp.stop(true); | |
| 67 | servers.stop(); | |
| 68 | app.close(); | |
| 69 | await rm(dataDir, { recursive: true, force: true }); | |
| 70 | }); | |
| 71 | ||
| 72 | test("only a writer is offered the button", async () => { | |
| 73 | expect(await page(aliceRw)).toContain('action="/alice/hello/app/pull"'); | |
| 74 | expect(await page(bobRw)).not.toContain("/app/pull"); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a writer's same-origin POST records a run and returns to the app page", async () => { | |
| 78 | const res = await send(aliceRw, servers.url); | |
| 79 | expect(res.status).toBe(303); | |
| 80 | expect(res.headers.get("location")).toContain("/alice/hello"); | |
| 81 | const runs = listPullRuns(app.db, appId); | |
| 82 | expect(runs.length).toBe(1); | |
| 83 | expect(runs[0]).toMatchObject({ state: "ok", written: 2, cursor: "r1" }); | |
| 84 | }); | |
| 85 | ||
| 86 | test("a reader gets 404 and nothing is recorded", async () => { | |
| 87 | expect((await send(bobRw, servers.url)).status).toBe(404); | |
| 88 | expect(listPullRuns(app.db, appId).length).toBe(1); | |
| 89 | }); | |
| 90 | ||
| 91 | test("a cross-origin POST is refused", async () => { | |
| 92 | const res = await send(aliceRw, "http://evil.example"); | |
| 93 | expect(res.status).toBe(403); | |
| 94 | expect(listPullRuns(app.db, appId).length).toBe(1); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a refusal shows its sentence in one muted line, and an unknown code shows nothing", async () => { | |
| 98 | awake = false; | |
| 99 | const res = await send(aliceRw, servers.url); | |
| 100 | expect(res.headers.get("location")).toContain("pull=asleep"); | |
| 101 | const shown = await page(aliceRw, "?pull=asleep"); | |
| 102 | expect(shown).toContain(`<p class="muted small">${PULL_NOW_REFUSALS.asleep.replace("'", "'")}</p>`); | |
| 103 | const section = async (qs: string) => { | |
Trailing whitespace after the semicolon; also no terminal test exists. |
||
| 104 | const text = await page(aliceRw, qs); | |
| 105 | return text.slice(text.indexOf("<h2>Adapter"), text.indexOf("</section>", text.indexOf("<h2>Adapter"))); | |
| 106 | }; | |
| 107 | expect(await section("?pull=%3Cb%3E")).not.toContain("muted small"); | |
| 108 | expect(await section("?pull=constructor")).not.toContain("muted small"); | |
| 109 | awake = true; | |
| 110 | }); |
Both doors: the page can now pull but the terminal Adapter screen cannot. Add the same writer-only action in src/tui/screens/adapter.ts calling pullNow.