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

An admin signed in at the terminal is asked once to work day to day as an everyday account, as the home card asks #8086

Merged opened this

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

Merged

mason/forge:mason/8046-an-admin-signed-in-at-the-terminal-is-as → main 12 commits fork 1 thread · 1 unresolved

  • Merged. mason/8046-an-admin-signed-in-at-the-terminal-is-as was merged into main as ef86f41.

Review

Sign in to review this change.
6 files changed +179 −1
README.md +1
@@ -460,6 +460,7 @@ This page is the front door. Everything else is a file of its own under
460460 - Visibility at the terminal: on the About card (`I`) a repository admin who is a person sees `V make private` on a public repository and `V make public` on a private one. `V` opens a confirm that names who will see it (public: anyone with the link, signed in or not; private: only its members and the crew, while forks and clones already made keep what they have), with Cancel selected first, so enter alone changes nothing. Confirming calls `updateRepoSettings`, as the web About form and MCP do, and toasts `now public`, `now private` or the refusal (a sealed report stays private until it merges). A writer who is not an admin and an agent are not offered `V`; an agent that administers its own repository changes visibility on the web or through MCP, not here.
461461 - Prompt source at the terminal: `i` on the Deploys screen opens the app's Instagram prompt source, as Settings › Prompts does, and is offered only to the repository's admin who is a person (an agent, a reader or a guest sees no `i` and it does nothing). It lists the account, when it last ran and what it said, and when the token is refreshed again. `a` connects one (the token typed masked, then the hour in UTC; it is never drawn or logged), `f` fetches now and says what came back, `d` removes it after a `y/n` confirm. A second `f` while a fetch is running is ignored. Refusals are the page's words (`invalid_token`, `invalid_at`, `no_such_source`, `not_running`, and `already_connected` only when an account was connected on the web while the screen was open: the screen offers `a` only when there is none).
462462 - Stop running as an app at the terminal: `X` on the Deploys screen, offered only to the repository's admin who is a person while it is an app (a writer who is not an admin, an agent, a reader or a guest sees no `X` and it does nothing). It asks you to type `<owner>/<name>`; anything else says `Type <owner>/<name> to confirm.` and changes nothing. The exact name calls `disableApp`, as Settings' Stop running as an app does: production and every preview stop and their data and checkouts are removed, the code is untouched. It toasts `no longer an app` or the refusal, and the screen re-reads and says the repository is no longer an app.
463 - Everyday account at the terminal: a forge admin with no choice made sees a muted line at the head of the dashboard asking, as the web home card does. `E` opens Settings on the web to make the everyday account (it is made with a passkey, which the terminal cannot hold); `K` keeps admin, and the web stops asking too. Once linked, the line names the everyday account. Anyone else, and an agent, sees neither key.
463464 - Events at the terminal: `v` on the About card (`I`) lists the repository's events newest first, 50 at a time; `n` loads the next page, `s` keeps only settings changes (the same `kind=repo.settings_updated` filter) and shows each as who changed what, from what to what (`alice changed repository settings: requireReview false → true`), `esc` goes back; it reads once on opening and does not refresh while open. The audience is `GET …/events`'s and MCP `events`' (a webhook's URL and an app's variable names are the admins').
464465 - Why it is not moving: MCP `flow` with `{ repo }`, `{ repo, issue }` or `{ repo, pull }` (a writer's) answers the first gate that holds it (`ceiling`, `hot_file`, `blocked_by`, `later_gate`, `no_builder`, `no_headroom`, `red_check`, `waiting_review`) with the action that clears it, or `state: closed`/`merged` when nothing is left to move; a pull is read against the head git has now. The terminal's About card (`I`) carries the repository's answer in a **Moving** row for a writer (`src/flow/why.ts`).
465466 - [`docs/FLOW.md`](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
docs/TOUR.md +2
@@ -215,6 +215,8 @@ The app's admin also sees its own domains on that screen, one line with each dom
215215
216216 A forge admin takes a snapshot from the terminal's **Resilience** dialog (`ctrl+k`, Resilience) with `n`, brings the off-box mirror level with `l` and runs a restore drill with `r`, each after a `y/n` asked on the dialog's first line. They are the Backups page's buttons' and MCP `snapshot_now`'s and `mirror_now`'s actions (`src/actions/backups_now.ts`, `drillNow` for the drill), so every door says the same words, and the drill's verdict is the Backups page's; the dialog lists the three newest snapshots, newest first (or that they could not be read), and re-reads them after each. The three keys are shown only to a forge admin, and while one action runs none of them is offered. Nothing names a key or a secret. Anyone else has no Resilience command, and the actions refuse them with `not_found`.
217217
218 A forge admin signed in at the terminal who has not yet chosen how to work day to day sees one muted line at the head of the dashboard, the web home card's ask in its words (`askForEveryday` in `src/core/everyday.ts`): `E` opens Settings on the web to make the everyday account, because that account is made with a passkey and a terminal cannot hold one, so the terminal never makes it itself; `K` keeps working as admin through the same `keepAdmin` the web reads, so neither door asks again. Once an everyday account is linked the line names it instead (`Day to day you are <name>; this is the admin account.`). Anyone who is not an admin, and an agent, sees no line and neither key; `k` still moves the cursor.
219
218220 The `app_vital` board source (`src/boards/sources.ts`) charts those reports: one row per app per day with `app` (the app's id), `day`, `load_p75`, `inp_p75` (ms) and `reports` (rows kept that day), the percentiles nearest-rank per app per day as `reach` does. `max` or `avg` over several days summarises daily percentiles, not the percentile of the whole range. Its rows belong to the app's repository, so a query sees only the repos in its scope. Today boards query with `repoIds: "all"` (`src/web/pages/boards.ts`) and only a site admin reaches them; no non-admin scope exists, so nothing yet applies the settings page's `canAdmin` gate per repository, and whoever builds one must. Days older than the 90-day trim are gone, not zero. The slug and the visitor hash are not columns. No dependency was added. Left out: a starting board for it; pick `app_vital` in the `/boards` source picker.
219221
220222 `reach` is a boards source for an admin only (anyone else gets 403, an agent too): `visitors` per day, `lcp_p75` and `inp_p75` per route per day, `server_p50` and `server_p95` per route per day (the beacon's time to first byte, the only per-route server timing the forge keeps), and `accounts_active_1d`, `accounts_active_7d` and `accounts_total` as of now. Filter by `metric`, group by `route` or a time bucket. Percentiles are nearest-rank within one route and day, so a `max` or `avg` over several days is a summary of daily percentiles, not a percentile of the period. Account activity comes from a session's last-seen time, so it is a count as of now, not a history. No dependency was added.
src/tui/screens/dashboard.ts +39
@@ -28,6 +28,7 @@ import { agentsLine } from "../../web/studio/client_home.ts";
2828 import { lastVisit } from "../../core/home_visit.ts";
2929 import { sinceDigest } from "../../web/studio/since.ts";
3030 import { askForSomething } from "./tell.ts";
31 import { askForEveryday, everydayOf, EVERYDAY_WHY, keepAdmin } from "../../core/everyday.ts";
3132 import { mayView } from "../../core/sealed.ts";
3233 import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts";
3334 import { plain } from "./boards.ts";
@@ -210,6 +211,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
210211 let decider: string | null = null;
211212 /** Home's three figures, as the web's row says them; empty unless an admin looks (they count every repository). */
212213 let figures: string[] = [];
214 /**
215 * The web home's everyday ask, as one muted line at the head (#8046): why,
216 * and the two answers, while an admin has made no choice; once linked, the
217 * name of the account used day to day. Nobody else gets the row.
218 */
219 let everyday: { line: string; asking: boolean } | undefined;
213220 /** One line per fired flow alarm; an admin reads every repository, anyone else public ones alone. */
214221 let alarms: string[] = [];
215222 /** The panels this admin pinned to the web home, by title and board, in their order. */
@@ -295,10 +302,29 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
295302 ctx.invalidate();
296303 };
297304
305 const everydayHead = (): typeof everyday => {
306 if (f.user === undefined || !f.user.isAdmin || f.user.kind !== "user") return undefined;
307 if (askForEveryday(f.db, f.user))
308 return { line: `${EVERYDAY_WHY} E makes it on the web · K keeps using admin`, asking: true };
309 const daily = everydayOf(f.db, f.user);
310 return daily === undefined
311 ? undefined
312 : { line: `Day to day you are ${daily.username}; this is the admin account.`, asking: false };
313 };
314
315 /** `K`: the admin keeps working as admin, recorded for web and terminal alike. */
316 const keepAdminHere = (): void => {
317 if (f.user === undefined) return;
318 keepAdmin(f.db, f.user);
319 reload();
320 ctx.toast("kept admin; you will not be asked again", "success");
321 };
322
298323 const reload = (): void => {
299324 // The server remembers the *choice* and nothing about the laptop, so
300325 // this is a list of decisions — never a status that would be stale.
301326 pins = f.user ? listPins(f.db, f.user.id) : [];
327 everyday = everydayHead();
302328 pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>();
303329 all = listReposVisibleTo(f.db, f.user, { limit: 200 });
304330 onYou = assignedToMe();
@@ -953,6 +979,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
953979 ...(focus === "repos" && f.user !== undefined && shown().some(mayArchive)
954980 ? [binding(["space"], "mark"), ...(markedRepos.size > 0 ? [binding(["a"], "archive marked")] : [])]
955981 : []),
982 ...(everyday?.asking === true
983 ? [binding(["E"], "everyday account"), binding(["K"], "keep admin")]
984 : []),
956985 binding(["r"], "refresh"),
957986 ];
958987 },
@@ -970,6 +999,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
970999 // A site admin's forge strip, as the board's: "All fine" or a line
9711000 // per cause. Anyone else gets no rows and no reads.
9721001 let body = area;
1002 if (everyday !== undefined) {
1003 const [strip, rest] = split(body, "v", [len(1), fill(1)]);
1004 s.text(strip!.x, strip!.y, truncate(everyday.line, strip!.w), style(c.mutedFg, c.bg), strip!.x + strip!.w);
1005 body = rest!;
1006 }
9731007 if (alarms.length > 0) {
9741008 const [strip, rest] = split(body, "v", [len(alarms.length), fill(1)]);
9751009 alarms.forEach((line, i) =>
@@ -1197,6 +1231,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
11971231 ctx.push(migrateScreen(f, ctx));
11981232 return true;
11991233 }
1234 if (e.key === "E" && everyday?.asking === true) {
1235 openOnWeb(f, ctx, "/settings", "Making your everyday account");
1236 return true;
1237 }
1238 if (e.key === "K" && everyday?.asking === true) return keepAdminHere(), true;

E hands off to /settings on the web because the everyday account needs a passkey. Say that in the docs sentence so a person at the terminal knows why.

12001239 if (e.key === "A" && f.user) {
12011240 openOnWeb(f, ctx, "/settings", "Account settings");
12021241 return true;
test/tui-everyday.test.ts added +126
@@ -0,0 +1,126 @@
1 /** The terminal's dashboard asks an admin once to work day to day as an everyday account, as the web home does (#8046). */
2 import { afterAll, beforeAll, expect, 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 { createAgent } from "../src/actions/crew.ts";
7 import { createApp, type App as Forge } from "../src/app.ts";
8 import { askForEveryday, keptAdmin, linkEveryday } from "../src/core/everyday.ts";
9 import { createUser, getUserByName, type User } from "../src/core/users.ts";
10 import { createForgeApp } from "../src/tui/forge.ts";
11 import { headlessTerminal } from "../src/tui/term/terminal.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13 import { seedAdmin } from "./helpers/admin.ts";
14 import { settleApp } from "./helpers/tui.ts";
15
16 quietLogs();
17
18 const ASK = "K keeps using admin";
19 const WHY_START = "Admin can change everything here";
20
21 let dir: string;
22 let forge: Forge;
23 let alice: User;
24 let bob: User;
25
26 async function person(username: string, isAdmin = false): Promise<User> {
27 const made = await createUser(forge.db, {
28 origin: "seed",
29 trusted: true,
30 username,
31 email: `${username}@forge.test`,
32 password: "a-long-passphrase",
33 });
34 if (!made.ok) throw new Error(made.error);
35 if (isAdmin) forge.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.id);
36 return getUserByName(forge.db, username)!;
37 }
38
39 beforeAll(async () => {
40 dir = await mkdtemp(join(tmpdir(), "forge-tui-everyday-"));
41 forge = createApp({ dataDir: dir, port: 0 });
42 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
43 if (!a.ok) throw new Error("signup failed");
44 alice = a.value;
45 bob = await person("bob");
46 });
47
48 afterAll(async () => {
49 forge.close();
50 await rm(dir, { recursive: true, force: true });
51 });
52
53 function boot(user: User) {
54 const terminal = headlessTerminal(240, 32);
55 const { app } = createForgeApp(
56 forge,
57 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user },
58 { terminal, exitHooks: false },
59 );
60 app.start();
61 return {
62 text: () => app.screen.toText(),
63 settle: () => settleApp(app),
64 press: async (key: string) => {
65 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
66 await settleApp(app);
67 },
68 stop: () => app.stop(),
69 };
70 }
71
72 async function firstScreen(user: User): Promise<string> {
73 const h = boot(user);
74 await h.settle();
75 const text = h.text();
76 h.stop();
77 return text;
78 }
79
80 test("an admin with no choice made sees the line at the head; K keeps admin and the line goes for good", async () => {
81 expect(askForEveryday(forge.db, alice)).toBe(true);
82 const h = boot(alice);
83 await h.settle();
84 const head = h.text().split("\n").find((line) => line.includes(WHY_START));
85 expect(head).toBeDefined();
86 expect(head).toContain("E makes it on the web · K keeps using admin");
87
88 // `k` still moves the cursor: it never makes the choice.
89 await h.press("k");
90 expect(keptAdmin(forge.db, alice)).toBe(false);
91 expect(h.text()).toContain(ASK);
92
93 await h.press("K");
94 expect(keptAdmin(forge.db, alice)).toBe(true);
95 expect(h.text()).not.toContain(WHY_START);
96 h.stop();
97
98 // The choice is the web's too: a fresh session is not asked again.
99 expect(askForEveryday(forge.db, alice)).toBe(false);
100 expect(await firstScreen(alice)).not.toContain(WHY_START);
101 });
102
103 test("an admin with an everyday account linked is told its name instead", async () => {
104 const carol = await person("carol", true);
105 const daily = await person("dana");
106 linkEveryday(forge.db, carol, daily);
107 const text = await firstScreen(carol);
108 expect(text).toContain("Day to day you are dana; this is the admin account.");
109 expect(text).not.toContain(ASK);
110 // The everyday account itself is not an admin, and is asked nothing.
111 expect(await firstScreen(daily)).not.toContain("Day to day");
112 });
113
114 test("a non-admin and an agent never see the line", async () => {
115 expect(await firstScreen(bob)).not.toContain(WHY_START);
116 const made = await createAgent(forge, alice, { username: "everydaybot" });
117 if (!made.ok) throw new Error(String(made.error));
118 // Even an agent made an admin is not asked: the everyday account is a person's.
119 forge.db.query("UPDATE user SET is_admin = 1 WHERE username = 'everydaybot'").run();
120 const agent = getUserByName(forge.db, "everydaybot")!;
121 expect(agent.isAdmin).toBe(true);
122 expect(agent.kind).toBe("agent");
123 const text = await firstScreen(agent);
124 expect(text).not.toContain(WHY_START);
125 expect(text).not.toContain("Day to day");
126 });
test/tui-issue-flow-first.test.ts +5
@@ -30,6 +30,9 @@ describe("the terminal's issue screen puts an issue first with f", () => {
3030 let bob: User;
3131 let repo: Repo;
3232
33 /** Every terminal opened here, stopped before the database closes: a live one keeps drawing into the next file. */
34 const opened: { stop(): void }[] = [];
35
3336 const open = async (user: User, issue: Issue) => {
3437 const terminal = headlessTerminal(140, 40);
3538 const { app: tui, session } = createForgeApp(
@@ -38,6 +41,7 @@ describe("the terminal's issue screen puts an issue first with f", () => {
3841 { terminal, exitHooks: false },
3942 );
4043 tui.start();
44 opened.push(tui);
4145 await settleApp(tui);
4246 tui.push(issueScreen(session, tui.ctx, local(session, getRepoById(app.db, repo.id)!), issue.number));
4347 await settleApp(tui);
@@ -70,6 +74,7 @@ describe("the terminal's issue screen puts an issue first with f", () => {
7074 }, 30_000);
7175
7276 afterAll(async () => {
77 for (const tui of opened) tui.stop();
7378 await new Promise((r) => setTimeout(r, 100));
7479 app.close();
7580 await rm(dataDir, { recursive: true, force: true });
test/tui-system.test.ts +6 −1
@@ -157,7 +157,12 @@ describe("tui system history", () => {
157157
158158 describe("tui system telemetry grant", () => {
159159 const boot = (user: User) => {
160 const { app: tui, session } = createForgeApp(app, identity(user), { terminal: headlessTerminal(120, 50), exitHooks: false });
160 // The refusal is a toast read after a settle; a loaded run must not outlast it.
161 const { app: tui, session } = createForgeApp(app, identity(user), {
162 terminal: headlessTerminal(120, 50),
163 exitHooks: false,
164 toastMs: 10 * 60_000,
165 });
161166 tui.start();
162167 tui.ctx.push(systemScreen(tui.ctx, ["CPU"], session));
163168 const press = async (...keys: string[]) => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs