Public: anyone can read this; the crew can change it.
The app's terminal prints production's address and the newest open preview, gated like the pill #6461
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6047-the-app-s-terminal-prints-production-s-a → main 2 commits fork
6 files
changed +94
−18
docs/APPS.md +4 −1
| @@ -153,7 +153,10 @@ Under the box, four starter sentences are plain links (no script): | ||
| 153 | 153 | rev-list` of the deployed commit when the run finishes. The run page says |
| 154 | 154 | "Carries #3, #5" and `GET /api/v1/repos/:owner/:name/runs/:id` has `carries`. |
| 155 | 155 | In the terminal, open a repository and press `d` for its production deploys |
| 156 | (each with what it carries), `enter` for the log, `r` to refresh. | |
| 156 | (each with what it carries), `enter` for the log, `r` to refresh. A signed-in | |
| 157 | reader also sees `Production: <url>` and, when a pull's preview is up or | |
| 158 | asleep, `Newest preview: #N <title> <url>` — the pill's other side, same | |
| 159 | gate; a guest sees neither. | |
| 157 | 160 | - A **private** app answers only to someone the forge has vouched for |
| 158 | 161 | (`canRead` on the repository, then a signed grant); a public one to anyone. |
| 159 | 162 | - **Every app has a terminal.** `ssh -p 2222 <name>@<forge>` runs the app's |
src/actions/apps.ts +19 −1
| @@ -17,7 +17,8 @@ import { openPulls } from "../apps/door_pulls.ts"; | ||
| 17 | 17 | import { goldenPath, goldenPathLine } from "../apps/golden_path.ts"; |
| 18 | 18 | import { appVitals, type AppVitals } from "../apps/vitals.ts"; |
| 19 | 19 | import { appsRuntime, envsFor, type EnvStatus } from "../apps/runtime.ts"; |
| 20 | import { grantUrl, originFor } from "../apps/proxy.ts"; | |
| 20 | import { grantUrl, originFor, sideForViewer, sideReader } from "../apps/proxy.ts"; | |
| 21 | import { plainLine } from "../apps/side.ts"; | |
| 21 | 22 | import { scaffoldFiles } from "../apps/scaffold.ts"; |
| 22 | 23 | import { |
| 23 | 24 | isEnvName, |
| @@ -538,6 +539,23 @@ export function appPullLines(app: App, actor: User | undefined, repo: Repo): App | ||
| 538 | 539 | })); |
| 539 | 540 | } |
| 540 | 541 | |
| 542 | /** | |
| 543 | * The other side of the app for its production terminal, gated as the pill | |
| 544 | * is (an active reader of the repository; a guest gets none): production's | |
| 545 | * address, then the newest open preview when there is one. | |
| 546 | */ | |
| 547 | export function appSideLines(app: App, actor: User | undefined, repo: Repo): string[] { | |
| 548 | const record = getAppByRepo(app.db, repo.id); | |
| 549 | const runtime = appsRuntime(app); | |
| 550 | if (record === undefined || runtime === undefined) return []; | |
| 551 | if (sideReader(app.db, record, actor) === undefined) return []; | |
| 552 | const preview = sideForViewer(runtime, record, "production", actor); | |
| 553 | return [ | |
| 554 | `Production: ${runtime.urlFor(record, "production")}`, | |
| 555 | ...(preview === null ? [] : [`${preview.label} ${preview.url}`]), | |
| 556 | ].map(plainLine); | |
| 557 | } | |
| 558 | ||
| 541 | 559 | export type EnvError = "not_found" | "no_runtime" | "no_such_env"; |
| 542 | 560 | |
| 543 | 561 | /** The URL to open an environment at — via a grant for a private app. */ |
src/apps/proxy.ts +27 −11
| @@ -29,9 +29,10 @@ import { | ||
| 29 | 29 | canWrite, |
| 30 | 30 | canRead, |
| 31 | 31 | getRepoById, |
| 32 | type Repo, | |
| 32 | 33 | ownerInStanding, |
| 33 | 34 | } from "../core/repos.ts"; |
| 34 | import { getUserById, isActive } from "../core/users.ts"; | |
| 35 | import { getUserById, isActive, type User } from "../core/users.ts"; | |
| 35 | 36 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 36 | 37 | import { loadKey } from "../kernel/secret.ts"; |
| 37 | 38 | import { previewEnv, type EnvName } from "./data.ts"; |
| @@ -490,16 +491,31 @@ function sideFor( | ||
| 490 | 491 | who: GrantWho | undefined, |
| 491 | 492 | ): Side | null { |
| 492 | 493 | if (who === undefined) return null; |
| 494 | return sideForViewer(runtime, target.app, target.env, getUserById(runtime.app.db, who.id)); | |
| 495 | } | |
| 496 | ||
| 497 | /** The repository, when `viewer` is an active reader of it: the only one the other side is told to. */ | |
| 498 | export function sideReader(db: App["db"], record: AppRecord, viewer: User | undefined): Repo | undefined { | |
| 499 | if (viewer === undefined || !isActive(viewer)) return undefined; | |
| 500 | const repo = getRepoById(db, record.repoId); | |
| 501 | return repo !== undefined && canRead(db, repo, viewer) ? repo : undefined; | |
| 502 | } | |
| 503 | ||
| 504 | /** The gate and the reading of the pill's other side, for the pill and the terminal alike. */ | |
| 505 | export function sideForViewer( | |
| 506 | runtime: Pick<AppsRuntime, "app" | "urlFor" | "envStatus">, | |
| 507 | record: AppRecord, | |
| 508 | env: EnvName, | |
| 509 | viewer: User | undefined, | |
| 510 | ): Side | null { | |
| 493 | 511 | const db = runtime.app.db; |
| 494 | const viewer = getUserById(db, who.id); | |
| 495 | const repo = getRepoById(db, target.app.repoId); | |
| 496 | if (viewer === undefined || !isActive(viewer) || repo === undefined) return null; | |
| 497 | if (!canRead(db, repo, viewer)) return null; | |
| 498 | const app = { url: runtime.urlFor(target.app, "production") }; | |
| 499 | if (target.env !== "production") return sideOf(app, target.env, []); | |
| 512 | const repo = sideReader(db, record, viewer); | |
| 513 | if (repo === undefined || viewer === undefined) return null; | |
| 514 | const app = { url: runtime.urlFor(record, "production") }; | |
| 515 | if (env !== "production") return sideOf(app, env, []); | |
| 500 | 516 | const previews = openPulls(db, repo, viewer, 50).flatMap((p) => { |
| 501 | const env = previewEnv(p.number); | |
| 502 | const status = runtime.envStatus(target.app, env); | |
| 517 | const penv = previewEnv(p.number); | |
| 518 | const status = runtime.envStatus(record, penv); | |
| 503 | 519 | if (status === undefined) return []; |
| 504 | 520 | const state = |
| 505 | 521 | status.state === "running" |
| @@ -507,9 +523,9 @@ function sideFor( | ||
| 507 | 523 | : status.state === "asleep" || status.dormant !== undefined |
| 508 | 524 | ? "asleep" |
| 509 | 525 | : status.state; |
| 510 | return [{ number: p.number, title: p.title, state, url: runtime.urlFor(target.app, env) }]; | |
| 526 | return [{ number: p.number, title: p.title, state, url: runtime.urlFor(record, penv) }]; | |
| 511 | 527 | }); |
| 512 | return sideOf(app, target.env, previews); | |
| 528 | return sideOf(app, env, previews); | |
| 513 | 529 | } |
| 514 | 530 | |
| 515 | 531 | /** Whether the person a grant names may enter this app now. */ |
src/apps/side.ts +5
| @@ -31,3 +31,8 @@ export function sideOf(app: SideApp, environment: string, previews: readonly Sid | ||
| 31 | 31 | for (const p of previews) if (OPENABLE.has(p.state) && (newest === undefined || p.number > newest.number)) newest = p; |
| 32 | 32 | return newest === undefined ? null : { label: `Newest preview: #${newest.number} ${newest.title}`, url: newest.url }; |
| 33 | 33 | } |
| 34 | ||
| 35 | /** A line for a terminal as plain text: control characters gone. */ | |
| 36 | export function plainLine(text: string): string { | |
| 37 | return text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, ""); | |
| 38 | } |
src/tui/screens/deploys.ts +5 −3
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | * none. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { appPace, appPullLines, appVisitors, type AppPullLine } from "../../actions/apps.ts"; | |
| 14 | import { appPace, appPullLines, appSideLines, appVisitors, type AppPullLine } from "../../actions/apps.ts"; | |
| 15 | 15 | import { trendLine, visitorsLine } from "../../apps/vitals.ts"; |
| 16 | 16 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 17 | 17 | import { carriedByRuns, carriesText } from "../../core/carried.ts"; |
| @@ -52,6 +52,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 52 | 52 | let visitors = ""; |
| 53 | 53 | let trend = ""; |
| 54 | 54 | let pace = ""; |
| 55 | let side: string[] = []; | |
| 55 | 56 | let pulls: AppPullLine[] = []; |
| 56 | 57 | |
| 57 | 58 | const read = (): void => { |
| @@ -65,6 +66,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 65 | 66 | visitors = seen === undefined ? "" : visitorsLine(seen); |
| 66 | 67 | trend = seen === undefined ? "" : trendLine(seen.loadDays); |
| 67 | 68 | pace = appPace(f.app, f.user, repo); |
| 69 | side = appSideLines(f.app, f.user, repo); | |
| 68 | 70 | pulls = appPullLines(f.app, f.user, repo); |
| 69 | 71 | loaded = true; |
| 70 | 72 | ctx.invalidate(); |
| @@ -83,7 +85,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 83 | 85 | render(area, s) { |
| 84 | 86 | const c = ctx.theme.c; |
| 85 | 87 | const [head, upper, tail] = split(area, "v", [ |
| 86 | len(1 + (visitors === "" ? 0 : 1) + (trend === "" ? 0 : 1) + (pace === "" ? 0 : 1)), | |
| 88 | len(1 + (visitors === "" ? 0 : 1) + (trend === "" ? 0 : 1) + (pace === "" ? 0 : 1) + side.length), | |
| 87 | 89 | fill(1), |
| 88 | 90 | len(pulls.length === 0 ? 0 : pulls.length + 1), |
| 89 | 91 | ]); |
| @@ -106,7 +108,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 106 | 108 | style(c.mutedFg, c.bg), |
| 107 | 109 | head!.x + head!.w, |
| 108 | 110 | ); |
| 109 | [visitors, trend, pace] | |
| 111 | [visitors, trend, pace, ...side] | |
| 110 | 112 | .filter((line) => line !== "") |
| 111 | 113 | .forEach((line, i) => |
| 112 | 114 | s.text(head!.x, head!.y + 1 + i, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w), |
test/tui-deploys.test.ts +34 −2
| @@ -47,12 +47,12 @@ afterAll(async () => { | ||
| 47 | 47 | }); |
| 48 | 48 | |
| 49 | 49 | const screenText = async ( |
| 50 | who: User = alice, | |
| 50 | who: User | null = alice, | |
| 51 | 51 | of: Repo = repo, |
| 52 | 52 | ): Promise<string> => { |
| 53 | 53 | const { app, session } = createForgeApp( |
| 54 | 54 | forge, |
| 55 | { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who }, | |
| 55 | { sshUsername: who?.username ?? "guest", remoteAddress: "127.0.0.1", ...(who === null ? {} : { user: who }) }, | |
| 56 | 56 | { terminal: headlessTerminal(120, 30), exitHooks: false }, |
| 57 | 57 | ); |
| 58 | 58 | app.start(); |
| @@ -173,3 +173,35 @@ test("the app's open pulls list below the deploys with check word and preview, a | ||
| 173 | 173 | expect(text).not.toContain("Add a lamp"); |
| 174 | 174 | expect(text).not.toContain("Open pulls"); |
| 175 | 175 | }); |
| 176 | ||
| 177 | test("production's terminal names the newest open preview to a reader, and a guest sees neither", async () => { | |
| 178 | const { AppsRuntime } = await import("../src/apps/runtime.ts"); | |
| 179 | const { appSideLines } = await import("../src/actions/apps.ts"); | |
| 180 | const runtime = new AppsRuntime(forge, { domain: "apps.test", scheme: "https", forgeUrl: "https://forge.test", concurrency: 1 }); | |
| 181 | const r = await createRepo(forge, alice, { name: "beacon", visibility: "public" }); | |
| 182 | if (!r.ok) throw new Error("repo failed"); | |
| 183 | const made = createAppRecord(forge.db, { repoId: r.value.id, slug: "beacon", access: "public" }); | |
| 184 | if (!made.ok) throw new Error(made.error); | |
| 185 | const id = crypto.randomUUID(); | |
| 186 | forge.db | |
| 187 | .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?, ?, 7, 'pull', 'Dim the beacon', ?)") | |
| 188 | .run(id, r.value.id, alice.id); | |
| 189 | forge.db | |
| 190 | .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', 'dim', 'b', 'h7')") | |
| 191 | .run(id); | |
| 192 | const real = runtime.envStatus.bind(runtime); | |
| 193 | runtime.envStatus = ((rec, env) => | |
| 194 | env === "pull-7" ? ({ state: "running" } as ReturnType<typeof real>) : real(rec, env)) as typeof runtime.envStatus; | |
| 195 | try { | |
| 196 | const lines = ["Production: https://beacon.apps.test", "Newest preview: #7 Dim the beacon https://beacon--7.apps.test"]; | |
| 197 | expect(appSideLines(forge, alice, r.value)).toEqual(lines); | |
| 198 | const text = await screenText(alice, r.value); | |
| 199 | for (const l of lines) expect(text).toContain(l); | |
| 200 | expect(appSideLines(forge, undefined, r.value)).toEqual([]); | |
| 201 | const guest = await screenText(null, r.value); | |
| 202 | expect(guest).not.toContain("Production:"); | |
| 203 | expect(guest).not.toContain("Newest preview"); | |
| 204 | } finally { | |
| 205 | await runtime.stop(); | |
| 206 | } | |
| 207 | }); |
new above
offline