Public: anyone can read this; the crew can change it.

The terminal shows the front page as a stranger sees it, ranked with its numbers, and a site admin pins or hides an app after a plan, as MCP `front_page` does #7745

Merged opened this

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

Merged

scout/forge:scout/7290-the-terminal-shows-the-front-page-as-a-s → main 3 commits fork

  • Merged. scout/7290-the-terminal-shows-the-front-page-as-a-s was merged into main as ec47d94.

Review

Sign in to review this change.
6 files changed +434 −5
docs/TOUR.md +18
@@ -253,6 +253,24 @@ The terminal's issue and pull screens stay whole on purpose: they are a
253253 scrolling text view of one thread already in memory, not a page a phone
254254 downloads, so there is no weight to cut and no second key to learn.
255255
256 ## The front page
257
258 What a stranger is shown at `/`, ranked with its numbers, and a site admin's
259 say over it: pinned apps first (the first public pin is featured), hidden apps
260 never, a private app never. Every door reads `frontJson` in
261 `src/actions/front.ts` and sets through `setFront`, so they never disagree.
262
263 - **Web:** `/` for everyone; the admin's front-page settings pin, hide and weigh.
264 - **API:** `GET` and `PUT /api/v1/front`.
265 - **MCP:** `front_page` reads the list; a site admin passes `pinned`, `hidden`
266 or `weights` (a dry run unless `dryRun: false`).
267 - **Terminal:** palette → "Front page" lists the same apps in the same order,
268 each with forks, ships, use and fresh. For a site admin who is a person, `p`
269 pins the selected app first (or unpins it) and `h` hides it (or shows it
270 again, from the hidden rows at the end); each shows the plan, the dry run of
271 `setFront`, and applies on Apply. Anyone else, signed out included, has the
272 list and neither key.
273
256274 ## Who can see it
257275
258276 Every repository page says, beside the Private/Public badge, who can see it in
src/actions/front.ts +22 −5
@@ -486,13 +486,30 @@ export function frontInput(
486486 return { pinned, hidden, weights };
487487 }
488488
489 /** One app on the front page as JSON. */
490 export type FrontRowJson = {
491 readonly repo: string;
492 readonly description: string;
493 readonly url: string;
494 readonly state: EnvState | null;
495 readonly picture: string | null;
496 readonly pinned: boolean;
497 readonly score: number;
498 readonly signals: FrontSignals;
499 };
500
501 /** The front page as JSON, as `frontJson` answers it. */
502 export type FrontJson = {
503 readonly featured: (FrontRowJson & { readonly crew: CrewAtWork }) | null;
504 readonly apps: readonly FrontRowJson[];
505 readonly total: number;
506 readonly picks?: FrontPicksJson;
507 };
508
489509 /** The front page as JSON: what a stranger is shown; a site admin also gets the picks. */
490 export function frontJson(
491 app: App,
492 viewer: User | undefined,
493 ): Record<string, unknown> {
510 export function frontJson(app: App, viewer: User | undefined): FrontJson {
494511 const front = frontPage(app);
495 const row = (e: FrontEntry) => ({
512 const row = (e: FrontEntry): FrontRowJson => ({
496513 repo: `${e.repo.ownerName}/${e.repo.name}`,
497514 description: e.repo.description,
498515 url: `/${e.repo.ownerName}/${e.repo.name}`,
src/tui/screens/entries.ts +2
@@ -14,6 +14,7 @@ import { entry as cloud } from "./cloud.ts";
1414 import { entry as crewHandover } from "./crewhandover.ts";
1515 import { entry as crewSplits } from "./crewsplits.ts";
1616 import { entry as domains } from "./domains.ts";
17 import { entry as front } from "./front.ts";
1718 import { entry as forges } from "./forges.ts";
1819 import { entry as invites } from "./invites.ts";
1920 import { entry as limits } from "./limits.ts";
@@ -67,6 +68,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
6768 subscriptions,
6869 cloud,
6970 domains,
71 front,
7072 owner,
7173 forges,
7274 ];
src/tui/screens/front.ts added +158
@@ -0,0 +1,158 @@
1 /**
2 * The front page in the terminal: the apps a signed-out visitor is shown, in
3 * order, each with its score parts, as MCP `front_page` answers. A site admin
4 * who is a person also pins and hides after a plan; `setFront` decides, and
5 * a private app is never listed because `frontJson` never lists it.
6 */
7
8 import { frontJson, type FrontRowJson, frontPicksJson, setFront } from "../../actions/front.ts";
9 import { canRead, getRepo } from "../../core/repos.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import { fill, len, split } from "../ui/layout.ts";
13 import { style } from "../term/style.ts";
14 import { truncate } from "../term/width.ts";
15 import type { Session } from "../session.ts";
16 import { ListState, confirmScreen, drawList, type ListRow } from "./common.ts";
17 import type { PaletteEntry } from "./entries.ts";
18
19 type Signals = FrontRowJson["signals"];
20
21 interface FrontRow {
22 repo: string;
23 mark: "featured" | "pinned" | "hidden" | "";
24 score: number | undefined;
25 signals: Signals | undefined;
26 }
27
28 const meta = (r: FrontRow): string =>
29 r.signals === undefined || r.score === undefined
30 ? "kept off the page"
31 : `score ${r.score} · forks ${r.signals.forks} · ships ${r.signals.ships} · use ${Math.round(r.signals.awakeHours)}h · fresh ${r.signals.shippedAt?.slice(0, 10) ?? "never"}`;
32
33 export function frontScreen(f: Session, ctx: Ctx): Screen {
34 const state = new ListState();
35 let rows: FrontRow[] = [];
36 let pinned: string[] = [];
37 let hidden: string[] = [];
38 let loaded = false;
39
40 /** A site admin who is a person: the only one who changes the page. */
41 const mayEdit = (): boolean => f.user?.isAdmin === true && f.user.kind !== "agent";
42
43 const refresh = (): void => {
44 // The list is the stranger's: no viewer, whoever is looking.
45 const page = frontJson(f.app, undefined);
46 const shown: FrontRow[] = [];
47 if (page.featured !== null)
48 shown.push({ repo: page.featured.repo, mark: "featured", score: page.featured.score, signals: page.featured.signals });
49 for (const a of page.apps)
50 shown.push({ repo: a.repo, mark: a.pinned ? "pinned" : "", score: a.score, signals: a.signals });
51 if (mayEdit()) {
52 const picks = frontPicksJson(f.app);
53 pinned = [...picks.pinned];
54 hidden = [...picks.hidden];
55 for (const name of hidden) {
56 const [owner, repoName] = name.split("/");
57 const repo = owner && repoName ? getRepo(f.app.db, owner, repoName) : undefined;
58 // A private app is not listed for anyone, hidden or not.
59 if (repo !== undefined && canRead(f.app.db, repo, undefined))
60 shown.push({ repo: name, mark: "hidden", score: undefined, signals: undefined });
61 }
62 }
63 rows = shown;
64 loaded = true;
65 state.clamp(rows.length);
66 ctx.invalidate();
67 };
68
69 const plan = (repo: string, action: "pin" | "hide"): { pinned: string[]; hidden: string[]; line: string } => {
70 const isPinned = pinned.includes(repo);
71 const isHidden = hidden.includes(repo);
72 if (action === "pin")
73 return isPinned
74 ? { pinned: pinned.filter((p) => p !== repo), hidden, line: `Unpin ${repo}: it ranks by score again.` }
75 : { pinned: [repo, ...pinned], hidden: hidden.filter((h) => h !== repo), line: `Pin ${repo} first: it is featured.` };
76 return isHidden
77 ? { pinned, hidden: hidden.filter((h) => h !== repo), line: `Show ${repo}: strangers may see it again.` }
78 : { pinned: pinned.filter((p) => p !== repo), hidden: [...hidden, repo], line: `Hide ${repo}: strangers no longer see it.` };
79 };
80
81 const ask = (action: "pin" | "hide"): void => {
82 if (!mayEdit()) return;
83 const row = rows[state.index];
84 if (row === undefined) return;
85 const next = plan(row.repo, action);
86 // The plan is the action's own dry run, so it says what would be refused.
87 const dry = setFront(f.app, f.user, { pinned: next.pinned, hidden: next.hidden }, true);
88 if (!dry.ok) {
89 ctx.toast(dry.error.kind === "forbidden" ? "Only a site admin sets the front page." : dry.error.message, "danger");
90 return;
91 }
92 ctx.push(
93 confirmScreen(ctx, screen, {
94 title: action === "pin" ? "Pin" : "Hide",
95 body: [
96 next.line,
97 `Pinned: ${dry.value.pinned.join(", ") || "none"}`,
98 `Hidden: ${dry.value.hidden.join(", ") || "none"}`,
99 ],
100 actions: [{ label: "Apply" }, { label: "Cancel" }],
101 selected: 1,
102 onChoose: (choice) => {
103 if (choice !== 0) return;
104 const done = setFront(f.app, f.user, { pinned: next.pinned, hidden: next.hidden });
105 ctx.toast(done.ok ? next.line : done.error.kind === "forbidden" ? "Only a site admin sets the front page." : done.error.message, done.ok ? undefined : "danger");
106 refresh();
107 },
108 }),
109 );
110 };
111
112 const screen: Screen = {
113 title: "Front page",
114 get keys() {
115 return [
116 binding(["j", "k"], "move cursor"),
117 ...(mayEdit() ? [binding(["p"], "pin or unpin"), binding(["h"], "hide or show")] : []),
118 ];
119 },
120 load() {
121 refresh();
122 return Promise.resolve();
123 },
124 render(area, s) {
125 const c = ctx.theme.c;
126 const [head, list] = split(area, "v", [len(1), fill(1)]);
127 const note = !loaded ? "Loading…" : rows.length === 0 ? "No app is on the front page." : "What a signed-out visitor sees, in order";
128 s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
129 const lines: ListRow[] = rows.map((r) => ({
130 title: r.repo,
131 secondary: r.mark,
132 meta: meta(r),
133 }));
134 drawList(s, list!, c, state, lines);
135 },
136 key(e) {
137 if (e.key === "p" && mayEdit()) {
138 ask("pin");
139 return true;
140 }
141 if (e.key === "h" && mayEdit()) {
142 ask("hide");
143 return true;
144 }
145 return state.key(e, rows.length);
146 },
147 };
148 return screen;
149 }
150
151 /** The stranger's front page, ranked with its numbers, as MCP `front_page` has it. */
152 export const entry: PaletteEntry = {
153 id: "front",
154 title: "Front page",
155 hint: "the apps a first visitor sees, ranked; an admin pins or hides",
156 offered: () => true,
157 run: (session, ctx) => ctx.push(frontScreen(session, ctx)),
158 };
test/palette-roster.test.ts +4
@@ -100,6 +100,7 @@ test("a site-admin person is offered the whole roster", () => {
100100 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
101101 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
102102 ["domains", "Failure domains", "failure domains and their last drill"],
103 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
103104 ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"],
104105 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
105106 ["family", "Family", "the forge that sent this one, and the forges it sent"],
@@ -143,6 +144,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
143144 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
144145 ["can", "What I can do", "your role and what it allows here"],
145146 ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"],
147 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
146148 ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"],
147149 ["family", "Family", "the forge that sent this one, and the forges it sent"],
148150 ["chain", "Chain", "the forges this forge's code descends from, root first"],
@@ -180,6 +182,7 @@ test("a member person is offered the signed-in roster without the admin entries"
180182 ["proposal-tokens", "Proposal tokens", "mint, list and revoke what a child proposes with"],
181183 ["model", "Model budget", "what your crew may spend, and add your own subscription"],
182184 ["can", "What I can do", "your role and what it allows here"],
185 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
183186 ["family", "Family", "the forge that sent this one, and the forges it sent"],
184187 ["chain", "Chain", "the forges this forge's code descends from, root first"],
185188 ["quit", "Quit", "disconnect"],
@@ -202,6 +205,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
202205 ["signin", "Sign in", undefined],
203206 ["api", "API", "the forge's endpoints; run a GET"],
204207 ["changelog", "Changelog", "the last 30 releases and their pulls"],
208 ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"],
205209 ["family", "Family", "the forge that sent this one, and the forges it sent"],
206210 ["chain", "Chain", "the forges this forge's code descends from, root first"],
207211 ["quit", "Quit", "disconnect"],
test/tui-front.test.ts added +230
@@ -0,0 +1,230 @@
1 /**
2 * The front page in the terminal: the stranger's list in MCP `front_page`'s
3 * order with its score parts; a site admin who is a person pins and hides
4 * after a plan; anyone else has neither key; a private app is never listed.
5 */
6 import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
7 import { mkdirSync, writeFileSync } from "node:fs";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App as Forge } from "../src/app.ts";
12 import { frontJson, forgetFrontScores } from "../src/actions/front.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
15 import { createApp as insertApp, type AppRecord } from "../src/core/apps.ts";
16 import { recordLook } from "../src/core/looks.ts";
17 import { encodePng } from "../src/design/image/png.ts";
18 import { createUser, getUserByName } from "../src/core/users.ts";
19 import { tool } from "../src/http/mcp/tools/front_page.ts";
20 import { serve } from "../src/http/server.ts";
21 import { createForgeApp } from "../src/tui/forge.ts";
22 import type { TuiIdentity } from "../src/tui/session.ts";
23 import { headlessTerminal } from "../src/tui/term/terminal.ts";
24 import { promote } from "./helpers/members.ts";
25 import { quietLogs } from "./helpers/globals.ts";
26 import { settleApp } from "./helpers/tui.ts";
27
28 quietLogs();
29
30 const HEAD = "a".repeat(40);
31 const PNG = Buffer.from(encodePng(1, 1, new Uint8Array([255, 255, 255, 255])));
32
33 describe("the front page in the terminal", () => {
34 let dataDir: string;
35 let forge: Forge;
36 let runtime: AppsRuntime;
37 let status: ReturnType<typeof spyOn>;
38
39 function boot(username: string | undefined, agent = false) {
40 const terminal = headlessTerminal(140, 32);
41 const user =
42 username === undefined
43 ? undefined
44 : { ...getUserByName(forge.db, username)!, ...(agent ? { kind: "agent" as const } : {}) };
45 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
46 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
47 app.start();
48 const press = async (...keys: string[]) => {
49 for (const k of keys) app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
50 await settleApp(app);
51 };
52 const open = async () => {
53 app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
54 await settleApp(app);
55 for (const ch of "Front page") app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
56 await settleApp(app);
57 await press("enter");
58 };
59 return { text: () => app.screen.toText(), press, open, stop: () => app.stop() };
60 }
61
62 const make = async (owner: string, name: string, visibility: "public" | "private") => {
63 const made = await createRepo(forge, getUserByName(forge.db, owner)!, { name, visibility, description: name });
64 if (!made.ok) throw new Error(String(made.error));
65 const record = insertApp(forge.db, { repoId: made.value.id, slug: name, access: "public" });
66 if (!record.ok) throw new Error(record.error);
67 mkdirSync(join(dataDir, "looks", record.value.id), { recursive: true });
68 writeFileSync(join(dataDir, "looks", record.value.id, "thumb.png"), PNG);
69 recordLook(forge.db, {
70 appId: record.value.id,
71 env: "production",
72 commitOid: HEAD,
73 kind: "thumb",
74 path: `looks/${record.value.id}/thumb.png`,
75 });
76 return made.value;
77 };
78
79 const mcpOrder = (): string[] => {
80 const out = tool.run({ app: forge } as never, getUserByName(forge.db, "alice")!, {}) as never as { value: unknown };
81 const v = out.value as { featured: { repo: string } | null; apps: { repo: string }[] };
82 return [...(v.featured ? [v.featured.repo] : []), ...v.apps.map((a) => a.repo)];
83 };
84
85 beforeAll(async () => {
86 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-front-"));
87 forge = createApp({ dataDir, port: 0 });
88 for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) {
89 const made = await createUser(forge.db, {
90 origin: "seed",
91 trusted: true,
92 username,
93 email: `${username}@forge.local`,
94 password: "a-good-passphrase",
95 adminIfFirst,
96 });
97 if (!made.ok) throw new Error(made.error);
98 promote(forge.db, made.value);
99 }
100 forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run();
101 status = spyOn(AppsRuntime.prototype, "status").mockImplementation(
102 (_r: AppRecord): EnvStatus[] => [{ env: "production", state: "running", commitOid: HEAD } as EnvStatus],
103 );
104 runtime = new AppsRuntime(forge, {
105 domain: "front.test",
106 scheme: "http",
107 publicPort: 1,
108 forgeUrl: "http://localhost:1",
109 });
110 await make("alice", "one", "public");
111 await make("alice", "two", "public");
112 await make("bob", "three", "public");
113 await make("bob", "secret", "private");
114 forgetFrontScores(forge);
115 });
116
117 afterAll(async () => {
118 status.mockRestore();
119 await runtime.stop();
120 forge.close();
121 await rm(dataDir, { recursive: true, force: true });
122 });
123
124 test("lists the stranger's apps in MCP front_page's order, never a private one", async () => {
125 const order = mcpOrder();
126 expect(order.length).toBe(3);
127 expect(order.some((r) => r.endsWith("/secret"))).toBe(false);
128 const t = boot("bob");
129 await t.open();
130 const text = t.text();
131 expect(text).toContain("What a signed-out visitor sees");
132 expect(text).toContain("forks 0");
133 expect(text).not.toContain("/secret");
134 const at = order.map((r) => text.indexOf(r));
135 expect(at.every((n) => n >= 0)).toBe(true);
136 expect([...at].sort((a, b) => a - b)).toEqual(at);
137 t.stop();
138 });
139
140 test("a non-admin has neither key", async () => {
141 const stranger = frontJson(forge, undefined);
142 const t = boot("bob");
143 await t.open();
144 const before = t.text();
145 await t.press("p");
146 expect(t.text()).toBe(before);
147 await t.press("h");
148 expect(t.text()).toBe(before);
149 t.stop();
150 // An admin agent is no person: no keys either.
151 const a = boot("carol", true);
152 await a.open();
153 const seen = a.text();
154 await a.press("h");
155 expect(a.text()).toBe(seen);
156 await a.press("p");
157 expect(a.text()).toBe(seen);
158 a.stop();
159 expect(frontJson(forge, undefined)).toEqual(stranger);
160 });
161
162 test("signed out, the list is the stranger's and p and h do nothing", async () => {
163 const stranger = frontJson(forge, undefined);
164 const t = boot(undefined);
165 await t.open();
166 const before = t.text();
167 for (const r of mcpOrder()) expect(before).toContain(r);
168 expect(before).not.toContain("/secret");
169 expect(before).not.toContain("pin or unpin");
170 await t.press("p");
171 expect(t.text()).toBe(before);
172 await t.press("h");
173 expect(t.text()).toBe(before);
174 t.stop();
175 expect(frontJson(forge, undefined)).toEqual(stranger);
176 });
177
178 test("a pin applied in the terminal reaches the web's stranger page", async () => {
179 const servers = await serve(forge);
180 try {
181 const seen = async () =>
182 ((await (await fetch(`${servers.url}/api/v1/front`)).json()) as { featured: { repo: string } | null }).featured?.repo;
183 const order = mcpOrder();
184 const last = order[order.length - 1]!;
185 expect(await seen()).not.toBe(last);
186 const t = boot("alice");
187 await t.open();
188 await t.press("j", "j", "p", "left", "enter");
189 expect(await seen()).toBe(last);
190 const door = await (await fetch(`${servers.url}/`)).text();
191 expect(door).toContain(last.split("/")[1]!);
192 // Put it back: unpin.
193 await t.press("g", "p", "left", "enter");
194 t.stop();
195 } finally {
196 servers.stop();
197 }
198 });
199
200 test("an admin pins the last app: the plan shows, and after the confirm it is first", async () => {
201 const order = mcpOrder();
202 const last = order[order.length - 1]!;
203 const t = boot("alice");
204 await t.open();
205 await t.press("j", "j", "p");
206 expect(t.text()).toContain(`Pin ${last} first`);
207 // Cancel is the default: nothing changed yet.
208 await t.press("escape");
209 expect(mcpOrder()).toEqual(order);
210 await t.press("p", "left", "enter");
211 expect(mcpOrder()[0]).toBe(last);
212 t.stop();
213 });
214
215 test("an admin hides an app: it leaves the list, and shows again from the hidden row", async () => {
216 const first = mcpOrder()[0]!;
217 const t = boot("alice");
218 await t.open();
219 await t.press("h", "left", "enter");
220 expect(mcpOrder()).not.toContain(first);
221 expect(t.text()).toContain("hidden");
222 // The hidden row sits at the end; `h` shows it again.
223 await t.press("G");
224 const rows = mcpOrder().length;
225 for (let i = 0; i < rows; i++) await t.press("j");
226 await t.press("h", "left", "enter");
227 expect(mcpOrder()).toContain(first);
228 t.stop();
229 });
230 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs