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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8046-an-admin-signed-in-at-the-terminal-is-as → main 12 commits fork 1 thread · 1 unresolved
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 | ||
| 460 | 460 | - 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. |
| 461 | 461 | - 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). |
| 462 | 462 | - 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. | |
| 463 | 464 | - 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'). |
| 464 | 465 | - 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`). |
| 465 | 466 | - [](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 | ||
| 215 | 215 | |
| 216 | 216 | 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`. |
| 217 | 217 | |
| 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 | ||
| 218 | 220 | 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. |
| 219 | 221 | |
| 220 | 222 | `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"; | ||
| 28 | 28 | import { lastVisit } from "../../core/home_visit.ts"; |
| 29 | 29 | import { sinceDigest } from "../../web/studio/since.ts"; |
| 30 | 30 | import { askForSomething } from "./tell.ts"; |
| 31 | import { askForEveryday, everydayOf, EVERYDAY_WHY, keepAdmin } from "../../core/everyday.ts"; | |
| 31 | 32 | import { mayView } from "../../core/sealed.ts"; |
| 32 | 33 | import { applyFleetIds, previewFleetIds, undoFleetOp } from "../../actions/fleet.ts"; |
| 33 | 34 | import { plain } from "./boards.ts"; |
| @@ -210,6 +211,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 210 | 211 | let decider: string | null = null; |
| 211 | 212 | /** Home's three figures, as the web's row says them; empty unless an admin looks (they count every repository). */ |
| 212 | 213 | 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; | |
| 213 | 220 | /** One line per fired flow alarm; an admin reads every repository, anyone else public ones alone. */ |
| 214 | 221 | let alarms: string[] = []; |
| 215 | 222 | /** 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 { | ||
| 295 | 302 | ctx.invalidate(); |
| 296 | 303 | }; |
| 297 | 304 | |
| 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 | ||
| 298 | 323 | const reload = (): void => { |
| 299 | 324 | // The server remembers the *choice* and nothing about the laptop, so |
| 300 | 325 | // this is a list of decisions — never a status that would be stale. |
| 301 | 326 | pins = f.user ? listPins(f.db, f.user.id) : []; |
| 327 | everyday = everydayHead(); | |
| 302 | 328 | pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>(); |
| 303 | 329 | all = listReposVisibleTo(f.db, f.user, { limit: 200 }); |
| 304 | 330 | onYou = assignedToMe(); |
| @@ -953,6 +979,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 953 | 979 | ...(focus === "repos" && f.user !== undefined && shown().some(mayArchive) |
| 954 | 980 | ? [binding(["space"], "mark"), ...(markedRepos.size > 0 ? [binding(["a"], "archive marked")] : [])] |
| 955 | 981 | : []), |
| 982 | ...(everyday?.asking === true | |
| 983 | ? [binding(["E"], "everyday account"), binding(["K"], "keep admin")] | |
| 984 | : []), | |
| 956 | 985 | binding(["r"], "refresh"), |
| 957 | 986 | ]; |
| 958 | 987 | }, |
| @@ -970,6 +999,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 970 | 999 | // A site admin's forge strip, as the board's: "All fine" or a line |
| 971 | 1000 | // per cause. Anyone else gets no rows and no reads. |
| 972 | 1001 | 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 | } | |
| 973 | 1007 | if (alarms.length > 0) { |
| 974 | 1008 | const [strip, rest] = split(body, "v", [len(alarms.length), fill(1)]); |
| 975 | 1009 | alarms.forEach((line, i) => |
| @@ -1197,6 +1231,11 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 1197 | 1231 | ctx.push(migrateScreen(f, ctx)); |
| 1198 | 1232 | return true; |
| 1199 | 1233 | } |
| 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; | |
| 1200 | 1239 | if (e.key === "A" && f.user) { |
| 1201 | 1240 | openOnWeb(f, ctx, "/settings", "Account settings"); |
| 1202 | 1241 | 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", () => { | ||
| 30 | 30 | let bob: User; |
| 31 | 31 | let repo: Repo; |
| 32 | 32 | |
| 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 | ||
| 33 | 36 | const open = async (user: User, issue: Issue) => { |
| 34 | 37 | const terminal = headlessTerminal(140, 40); |
| 35 | 38 | const { app: tui, session } = createForgeApp( |
| @@ -38,6 +41,7 @@ describe("the terminal's issue screen puts an issue first with f", () => { | ||
| 38 | 41 | { terminal, exitHooks: false }, |
| 39 | 42 | ); |
| 40 | 43 | tui.start(); |
| 44 | opened.push(tui); | |
| 41 | 45 | await settleApp(tui); |
| 42 | 46 | tui.push(issueScreen(session, tui.ctx, local(session, getRepoById(app.db, repo.id)!), issue.number)); |
| 43 | 47 | await settleApp(tui); |
| @@ -70,6 +74,7 @@ describe("the terminal's issue screen puts an issue first with f", () => { | ||
| 70 | 74 | }, 30_000); |
| 71 | 75 | |
| 72 | 76 | afterAll(async () => { |
| 77 | for (const tui of opened) tui.stop(); | |
| 73 | 78 | await new Promise((r) => setTimeout(r, 100)); |
| 74 | 79 | app.close(); |
| 75 | 80 | await rm(dataDir, { recursive: true, force: true }); |
test/tui-system.test.ts +6 −1
| @@ -157,7 +157,12 @@ describe("tui system history", () => { | ||
| 157 | 157 | |
| 158 | 158 | describe("tui system telemetry grant", () => { |
| 159 | 159 | 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 | }); | |
| 161 | 166 | tui.start(); |
| 162 | 167 | tui.ctx.push(systemScreen(tui.ctx, ["CPU"], session)); |
| 163 | 168 | const press = async (...keys: string[]) => { |
new above
offline
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.