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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6047-the-app-s-terminal-prints-production-s-a → main 2 commits fork

  • Merged. scout/6047-the-app-s-terminal-prints-production-s-a was merged into main as 3d3423b.

Review

Sign in to review this change.
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):
153153 rev-list` of the deployed commit when the run finishes. The run page says
154154 "Carries #3, #5" and `GET /api/v1/repos/:owner/:name/runs/:id` has `carries`.
155155 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.
157160 - A **private** app answers only to someone the forge has vouched for
158161 (`canRead` on the repository, then a signed grant); a public one to anyone.
159162 - **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";
1717 import { goldenPath, goldenPathLine } from "../apps/golden_path.ts";
1818 import { appVitals, type AppVitals } from "../apps/vitals.ts";
1919 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";
2122 import { scaffoldFiles } from "../apps/scaffold.ts";
2223 import {
2324 isEnvName,
@@ -538,6 +539,23 @@ export function appPullLines(app: App, actor: User | undefined, repo: Repo): App
538539 }));
539540 }
540541
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
541559 export type EnvError = "not_found" | "no_runtime" | "no_such_env";
542560
543561 /** 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 {
2929 canWrite,
3030 canRead,
3131 getRepoById,
32 type Repo,
3233 ownerInStanding,
3334 } from "../core/repos.ts";
34 import { getUserById, isActive } from "../core/users.ts";
35 import { getUserById, isActive, type User } from "../core/users.ts";
3536 import { err, ok, type Result } from "../kernel/result.ts";
3637 import { loadKey } from "../kernel/secret.ts";
3738 import { previewEnv, type EnvName } from "./data.ts";
@@ -490,16 +491,31 @@ function sideFor(
490491 who: GrantWho | undefined,
491492 ): Side | null {
492493 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 {
493511 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, []);
500516 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);
503519 if (status === undefined) return [];
504520 const state =
505521 status.state === "running"
@@ -507,9 +523,9 @@ function sideFor(
507523 : status.state === "asleep" || status.dormant !== undefined
508524 ? "asleep"
509525 : 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) }];
511527 });
512 return sideOf(app, target.env, previews);
528 return sideOf(app, env, previews);
513529 }
514530
515531 /** 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
3131 for (const p of previews) if (OPENABLE.has(p.state) && (newest === undefined || p.number > newest.number)) newest = p;
3232 return newest === undefined ? null : { label: `Newest preview: #${newest.number} ${newest.title}`, url: newest.url };
3333 }
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 @@
1111 * none.
1212 */
1313
14 import { appPace, appPullLines, appVisitors, type AppPullLine } from "../../actions/apps.ts";
14 import { appPace, appPullLines, appSideLines, appVisitors, type AppPullLine } from "../../actions/apps.ts";
1515 import { trendLine, visitorsLine } from "../../apps/vitals.ts";
1616 import { listRepoRuns } from "../../actions/runs.ts";
1717 import { carriedByRuns, carriesText } from "../../core/carried.ts";
@@ -52,6 +52,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
5252 let visitors = "";
5353 let trend = "";
5454 let pace = "";
55 let side: string[] = [];
5556 let pulls: AppPullLine[] = [];
5657
5758 const read = (): void => {
@@ -65,6 +66,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
6566 visitors = seen === undefined ? "" : visitorsLine(seen);
6667 trend = seen === undefined ? "" : trendLine(seen.loadDays);
6768 pace = appPace(f.app, f.user, repo);
69 side = appSideLines(f.app, f.user, repo);
6870 pulls = appPullLines(f.app, f.user, repo);
6971 loaded = true;
7072 ctx.invalidate();
@@ -83,7 +85,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
8385 render(area, s) {
8486 const c = ctx.theme.c;
8587 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),
8789 fill(1),
8890 len(pulls.length === 0 ? 0 : pulls.length + 1),
8991 ]);
@@ -106,7 +108,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
106108 style(c.mutedFg, c.bg),
107109 head!.x + head!.w,
108110 );
109 [visitors, trend, pace]
111 [visitors, trend, pace, ...side]
110112 .filter((line) => line !== "")
111113 .forEach((line, i) =>
112114 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 () => {
4747 });
4848
4949 const screenText = async (
50 who: User = alice,
50 who: User | null = alice,
5151 of: Repo = repo,
5252 ): Promise<string> => {
5353 const { app, session } = createForgeApp(
5454 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 }) },
5656 { terminal: headlessTerminal(120, 30), exitHooks: false },
5757 );
5858 app.start();
@@ -173,3 +173,35 @@ test("the app's open pulls list below the deploys with check word and preview, a
173173 expect(text).not.toContain("Add a lamp");
174174 expect(text).not.toContain("Open pulls");
175175 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs