Public: anyone can read this; the crew can change it.
The terminal's deploys screen opens an app's prompt source with `i`: the connected Instagram account, fetch now, connect or remove, as Settings › Prompts does #7564
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7465-the-terminal-s-deploys-screen-opens-an-a → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +438
−0
README.md +1
| @@ -418,6 +418,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 418 | 418 | - [](docs/SENTINEL.md) — the forge hunting its own faults; [](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`). |
| 419 | 419 | - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). While it is on, builders start only `flow` issues; assigning any other issue is recorded but starts no run and reads "queued: flow only" (web, terminal, API and MCP `issue`) until it gets the `flow` label or the switch goes off, and then it dispatches. Rebase, re-check, red-fix and review runs always go. On the forge's own repository, while it is off, each admin's home carries one **Flow only** button after the flow alarms (it posts to the setting's route and lands on the setting, where it turns off again); and once it is on and no flow alarm has stood for 24 h (the alarms the home shows, with the same crew ceiling) the minute's tick turns it off, once, with one feed item for admins; the terminal's About row reads "on, off after 24 h clear". The auto-off applies to the forge's own repository only (`src/flow/flow_only_auto.ts`). |
| 420 | 420 | - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`. |
| 421 | - 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). | |
| 421 | 422 | - 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, `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'). |
| 422 | 423 | - [](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. |
| 423 | 424 | - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [](docs/FLOW.md). |
src/tui/screens/deploys.ts +11
| @@ -32,6 +32,9 @@ | ||
| 32 | 32 | * — `addAppDomain`, `checkAppDomain` and `removeAppDomain`, the page's and |
| 33 | 33 | * MCP `app_domains`' actions. `listAppDomains` is the admin gate: anyone it |
| 34 | 34 | * refuses sees neither the domains nor the key. |
| 35 | * | |
| 36 | * The admin who is a person also gets `i`: the app's prompt source, the | |
| 37 | * Instagram account whose comments become the crew's tell (`prompts.ts`). | |
| 35 | 38 | */ |
| 36 | 39 | |
| 37 | 40 | import { |
| @@ -89,6 +92,7 @@ import { | ||
| 89 | 92 | } from "./common.ts"; |
| 90 | 93 | import { RunList, openRunLog, runRows } from "./runlist.ts"; |
| 91 | 94 | import { appMembersScreen } from "./appmembers.ts"; |
| 95 | import { mayManagePrompts, promptsScreen } from "./prompts.ts"; | |
| 92 | 96 | |
| 93 | 97 | const LIMIT = 50; |
| 94 | 98 | |
| @@ -139,6 +143,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 139 | 143 | let pulls: AppPullLine[] = []; |
| 140 | 144 | let production: Production = { state: "", redeploy: false, stop: false }; |
| 141 | 145 | let members = false; |
| 146 | let prompts = false; | |
| 142 | 147 | /** The app's domains, or undefined for anyone `listAppDomains` refuses. */ |
| 143 | 148 | let domains: AppDomainView[] | undefined; |
| 144 | 149 | |
| @@ -164,6 +169,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 164 | 169 | canWrite(f.db, repo, f.user); |
| 165 | 170 | const listed = listAppDomains(f.app, f.user, repo); |
| 166 | 171 | domains = listed.ok ? listed.value : undefined; |
| 172 | prompts = mayManagePrompts(f, repo); | |
| 167 | 173 | loaded = true; |
| 168 | 174 | ctx.invalidate(); |
| 169 | 175 | }; |
| @@ -219,6 +225,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 219 | 225 | ...(mayProbe ? [binding(["p"], "probe the running app's doors")] : []), |
| 220 | 226 | ...(members ? [binding(["m"], "members")] : []), |
| 221 | 227 | ...(domains !== undefined ? [binding(["n"], "the app's own domains")] : []), |
| 228 | ...(prompts ? [binding(["i"], "prompt source (Instagram)")] : []), | |
| 222 | 229 | ]; |
| 223 | 230 | }, |
| 224 | 231 | load: async () => read(), |
| @@ -318,6 +325,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 318 | 325 | ctx.push(domainsScreen(f, ctx, repo, read)); |
| 319 | 326 | return true; |
| 320 | 327 | } |
| 328 | if (e.key === "i" && prompts) { | |
| 329 | ctx.push(promptsScreen(f, ctx, repo)); | |
| 330 | return true; | |
| 331 | } | |
| 321 | 332 | return false; |
| 322 | 333 | }, |
| 323 | 334 | }; |
src/tui/screens/prompts.ts added +197
| @@ -0,0 +1,197 @@ | ||
| 1 | /** | |
| 2 | * An app's prompt source in the terminal, its admin's: the Instagram account | |
| 3 | * whose top comment on the latest Reel becomes the crew's next tell, as | |
| 4 | * Settings › Prompts shows it and MCP `prompts` reads it. `a` connects one | |
| 5 | * (the token typed masked, then the hour), `f` fetches now, `d` removes it | |
| 6 | * after a confirm. Every rule is the action's (`actions/prompts.ts`); the | |
| 7 | * token is typed once, handed to `addPromptSource` and never held, drawn or | |
| 8 | * logged here, and the views carry none. | |
| 9 | */ | |
| 10 | ||
| 11 | import { | |
| 12 | addPromptSource, | |
| 13 | listPromptSources, | |
| 14 | removePromptSource, | |
| 15 | runPromptSource, | |
| 16 | type PromptSourceView, | |
| 17 | } from "../../actions/prompts.ts"; | |
| 18 | import { REFRESH_AFTER_DAYS } from "../../crew/prompts.ts"; | |
| 19 | import type { Repo } from "../../core/repos.ts"; | |
| 20 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 21 | import { binding } from "../app/keys.ts"; | |
| 22 | import type { Session } from "../session.ts"; | |
| 23 | import { style } from "../term/style.ts"; | |
| 24 | import { truncate } from "../term/width.ts"; | |
| 25 | import { fill, len, split } from "../ui/layout.ts"; | |
| 26 | import { emptyState } from "../ui/widgets.ts"; | |
| 27 | import { | |
| 28 | ListState, | |
| 29 | ago, | |
| 30 | confirmScreen, | |
| 31 | drawList, | |
| 32 | hitList, | |
| 33 | later, | |
| 34 | promptScreen, | |
| 35 | runAsync, | |
| 36 | yesNo, | |
| 37 | type ListRow, | |
| 38 | } from "./common.ts"; | |
| 39 | ||
| 40 | /** The action's refusals, in the page's words. */ | |
| 41 | const ERRORS: Record<string, string> = { | |
| 42 | not_found: "Only the app's admin manages its prompt source.", | |
| 43 | invalid_at: "The time is hours and minutes in UTC, like 15:00.", | |
| 44 | invalid_token: "Paste the long-lived token as Instagram gave it: one line, no spaces.", | |
| 45 | invalid_kind: "Only Instagram is known here.", | |
| 46 | already_connected: "An Instagram account is already connected here. Remove it first.", | |
| 47 | no_such_source: "No account by that id.", | |
| 48 | not_running: "The prompt routine is not running on this forge.", | |
| 49 | }; | |
| 50 | ||
| 51 | const say = (code: string): string => ERRORS[code] ?? "That could not be done."; | |
| 52 | ||
| 53 | /** Whether the viewer is offered the screen: a person, never an agent, whom the action lets list. */ | |
| 54 | export function mayManagePrompts(f: Session, repo: Repo): boolean { | |
| 55 | return f.user !== undefined && f.user.kind !== "agent" && listPromptSources(f.app, f.user, repo).ok; | |
| 56 | } | |
| 57 | ||
| 58 | /** The day the forge will refresh a token pasted or refreshed at `iso`. */ | |
| 59 | const refreshDay = (iso: string): string => | |
| 60 | new Date(Date.parse(iso) + REFRESH_AFTER_DAYS * 24 * 3_600_000).toISOString().slice(0, 10); | |
| 61 | ||
| 62 | const clean = (text: string): string => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, " "); | |
| 63 | ||
| 64 | const row = (s: PromptSourceView): ListRow => ({ | |
| 65 | title: `${s.account === "" ? "not read yet" : `@${clean(s.account)}`} · every day at ${s.atUtc} UTC`, | |
| 66 | secondary: | |
| 67 | (s.lastRanAt === null ? "Has not run yet." : `Last ran ${ago(s.lastRanAt)}. ${clean(s.lastSaid)}`) + | |
| 68 | ` Token refreshed ${ago(s.tokenRefreshedAt)}; refreshed again around ${refreshDay(s.tokenRefreshedAt)}.`, | |
| 69 | }); | |
| 70 | ||
| 71 | export function promptsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 72 | const state = new ListState(); | |
| 73 | let sources: PromptSourceView[] = []; | |
| 74 | let refused = false; | |
| 75 | let loaded = false; | |
| 76 | ||
| 77 | const read = (): void => { | |
| 78 | refused = !mayManagePrompts(f, repo); | |
| 79 | const listed = refused ? undefined : listPromptSources(f.app, f.user, repo); | |
| 80 | sources = listed?.ok === true ? listed.value : []; | |
| 81 | loaded = true; | |
| 82 | ctx.invalidate(); | |
| 83 | }; | |
| 84 | ||
| 85 | const add = (): void => { | |
| 86 | ctx.push( | |
| 87 | promptScreen(ctx, self, { | |
| 88 | title: "Connect Instagram", | |
| 89 | label: "Long-lived token (never shown again)", | |
| 90 | mask: true, | |
| 91 | onSubmit: (token) => | |
| 92 | later(ctx, () => | |
| 93 | promptScreen(ctx, self, { | |
| 94 | title: "Connect Instagram", | |
| 95 | label: "Every day at … UTC", | |
| 96 | value: "15:00", | |
| 97 | onSubmit: (at) => { | |
| 98 | const added = addPromptSource(f.app, f.user, repo, { kind: "instagram", token, at: at.trim() }); | |
| 99 | if (!added.ok) return say(added.error); | |
| 100 | ctx.toast("Instagram is connected."); | |
| 101 | read(); | |
| 102 | return undefined; | |
| 103 | }, | |
| 104 | }), | |
| 105 | ), | |
| 106 | }), | |
| 107 | ); | |
| 108 | }; | |
| 109 | ||
| 110 | /** A fetch is a call to Instagram: one at a time, whatever the key repeat does. */ | |
| 111 | let fetching = false; | |
| 112 | const fetchNow = (s: PromptSourceView): void => { | |
| 113 | if (fetching) return; | |
| 114 | fetching = true; | |
| 115 | runAsync(ctx, async () => { | |
| 116 | try { | |
| 117 | const ran = await runPromptSource(f.app, f.user, repo, s.id); | |
| 118 | if (!ran.ok) ctx.toast(say(ran.error), "danger"); | |
| 119 | else ctx.toast(clean(ran.value.lastSaid) || "Fetched."); | |
| 120 | } finally { | |
| 121 | fetching = false; | |
| 122 | } | |
| 123 | read(); | |
| 124 | }); | |
| 125 | }; | |
| 126 | ||
| 127 | const remove = (s: PromptSourceView): void => { | |
| 128 | const choose = (choice: number): void => { | |
| 129 | if (choice !== 0) return; | |
| 130 | const gone = removePromptSource(f.app, f.user, repo, s.id); | |
| 131 | ctx.toast(gone.ok ? "Instagram is disconnected." : say(gone.error), gone.ok ? undefined : "danger"); | |
| 132 | read(); | |
| 133 | }; | |
| 134 | ctx.push( | |
| 135 | yesNo( | |
| 136 | ctx, | |
| 137 | confirmScreen(ctx, self, { | |
| 138 | title: "Remove prompt source", | |
| 139 | body: [`remove ${s.account === "" ? "the Instagram account" : `@${clean(s.account)}`}? y/n`], | |
| 140 | actions: [{ label: "Remove" }, { label: "Cancel", variant: "ghost" }], | |
| 141 | selected: 1, | |
| 142 | onChoose: choose, | |
| 143 | }), | |
| 144 | choose, | |
| 145 | ), | |
| 146 | ); | |
| 147 | }; | |
| 148 | ||
| 149 | const self: Screen = { | |
| 150 | title: "Prompts", | |
| 151 | get keys() { | |
| 152 | return refused | |
| 153 | ? [binding(["esc"], "back")] | |
| 154 | : [ | |
| 155 | binding(["j", "k"], "move"), | |
| 156 | ...(sources.length === 0 ? [binding(["a"], "connect Instagram")] : []), | |
| 157 | ...(sources.length > 0 ? [binding(["f"], "fetch now"), binding(["d"], "remove it")] : []), | |
| 158 | binding(["r"], "refresh"), | |
| 159 | binding(["esc"], "back"), | |
| 160 | ]; | |
| 161 | }, | |
| 162 | load: async () => read(), | |
| 163 | render(area, s) { | |
| 164 | const c = ctx.theme.c; | |
| 165 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 166 | s.text( | |
| 167 | head!.x, | |
| 168 | head!.y, | |
| 169 | truncate(`${repo.ownerName}/${repo.name} · the top comment on the latest Reel becomes the next tell`, head!.w), | |
| 170 | style(c.mutedFg, c.bg), | |
| 171 | head!.x + head!.w, | |
| 172 | ); | |
| 173 | if (refused || sources.length === 0) { | |
| 174 | emptyState(s, body!, c, { | |
| 175 | title: refused ? "Not yours" : loaded ? "No account connected" : "Reading…", | |
| 176 | body: refused ? say("not_found") : loaded ? "Press a to connect an Instagram account." : "Reading the prompt source", | |
| 177 | }); | |
| 178 | return; | |
| 179 | } | |
| 180 | const rows = sources.map(row); | |
| 181 | drawList(s, body!, c, state, rows); | |
| 182 | hitList(ctx, body!, state, rows.length, {}); | |
| 183 | }, | |
| 184 | key(e) { | |
| 185 | if (refused) return false; | |
| 186 | if (state.key(e, sources.length)) return true; | |
| 187 | const selected = sources[state.index]; | |
| 188 | if (e.key === "a" && sources.length === 0) add(); | |
| 189 | else if (e.key === "f" && selected !== undefined) fetchNow(selected); | |
| 190 | else if (e.key === "d" && selected !== undefined) remove(selected); | |
| 191 | else if (e.key === "r") read(); | |
| 192 | else return false; | |
| 193 | return true; | |
| 194 | }, | |
| 195 | }; | |
| 196 | return self; | |
| 197 | } |
test/tui-prompts.test.ts added +229
| @@ -0,0 +1,229 @@ | ||
| 1 | /** | |
| 2 | * The deploys screen's `i`: an app's prompt source. An admin connects an | |
| 3 | * account (the token typed masked, in no frame), fetches now through a fake | |
| 4 | * Instagram, removes it after a confirm; a reader and a guest are not | |
| 5 | * offered `i`. | |
| 6 | */ | |
| 7 | ||
| 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { addPromptSource, listPromptSources, removePromptSource } from "../src/actions/prompts.ts"; | |
| 13 | import { createUser, getUserById } from "../src/core/users.ts"; | |
| 14 | import { createAgentRow } from "../src/core/agents.ts"; | |
| 15 | import { listEvents } from "../src/core/events.ts"; | |
| 16 | import { createRepo } from "../src/actions/repos.ts"; | |
| 17 | import { signUp } from "../src/actions/users.ts"; | |
| 18 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import type { User } from "../src/core/users.ts"; | |
| 21 | import { startPromptRoutine, type PromptRoutineHandle } from "../src/crew/prompts.ts"; | |
| 22 | import type { InstagramClient } from "../src/crew/instagram.ts"; | |
| 23 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 24 | import { deploysScreen } from "../src/tui/screens/deploys.ts"; | |
| 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | import { settleApp } from "./helpers/tui.ts"; | |
| 28 | import { fixtureSlot } from "./helpers/slot.ts"; | |
| 29 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 30 | ||
| 31 | quietLogs(); | |
| 32 | ||
| 33 | const TOKEN = "IGAAtokenSECRET0123456789"; | |
| 34 | ||
| 35 | let dir: string; | |
| 36 | let forge: Forge; | |
| 37 | let routine: PromptRoutineHandle; | |
| 38 | let alice: User; | |
| 39 | let bob: User; | |
| 40 | let repo: Repo; | |
| 41 | let bot: User; | |
| 42 | let asked = 0; | |
| 43 | let hold: Promise<void> = Promise.resolve(); | |
| 44 | ||
| 45 | /** The action's injectable fetch: Instagram is this stub, never the network. */ | |
| 46 | const instagram: InstagramClient = { | |
| 47 | me: async () => { | |
| 48 | asked += 1; | |
| 49 | await hold; | |
| 50 | return { id: "1", username: "handle" }; | |
| 51 | }, | |
| 52 | latestReel: () => Promise.resolve(undefined), | |
| 53 | comments: () => Promise.resolve([]), | |
| 54 | refresh: () => Promise.resolve({ token: "fresh" }), | |
| 55 | }; | |
| 56 | ||
| 57 | beforeAll(async () => { | |
| 58 | dir = await mkdtemp(join(tmpdir(), "forge-tui-prompts-")); | |
| 59 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 60 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 61 | if (!a.ok) throw new Error("signup failed"); | |
| 62 | alice = a.value; | |
| 63 | const b = await signUp( | |
| 64 | forge, | |
| 65 | { username: "bob", email: "[email protected]", password: "a-long-passphrase" }, | |
| 66 | { slot: fixtureSlot(forge) }, | |
| 67 | ); | |
| 68 | if (!b.ok) throw new Error("signup failed"); | |
| 69 | bob = b.value; | |
| 70 | const r = await createRepo(forge, alice, { name: "notes", visibility: "public" }); | |
| 71 | if (!r.ok) throw new Error("repo failed"); | |
| 72 | repo = r.value; | |
| 73 | const made = await createUser(forge.db, { username: "bot", email: "[email protected]", agent: true, origin: "agent" }); | |
| 74 | if (!made.ok) throw new Error(made.error); | |
| 75 | createAgentRow(forge.db, { userId: made.value.id, brief: "bot." }); | |
| 76 | // A site-admin agent: the action would let it in, the screen must not. | |
| 77 | forge.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.id); | |
| 78 | bot = getUserById(forge.db, made.value.id)!; | |
| 79 | routine = startPromptRoutine(forge, { client: instagram, everyMs: 3_600_000 }); | |
| 80 | }); | |
| 81 | ||
| 82 | afterAll(async () => { | |
| 83 | await routine?.stop(); | |
| 84 | forge.close?.(); | |
| 85 | await rm(dir, { recursive: true, force: true }); | |
| 86 | }); | |
| 87 | ||
| 88 | const drive = async (who: User | null, of: Repo = repo) => { | |
| 89 | const { app, session } = createForgeApp( | |
| 90 | forge, | |
| 91 | { sshUsername: who?.username ?? "guest", remoteAddress: "127.0.0.1", ...(who === null ? {} : { user: who }) }, | |
| 92 | { terminal: headlessTerminal(160, 40), exitHooks: false }, | |
| 93 | ); | |
| 94 | app.start(); | |
| 95 | await settleApp(app); | |
| 96 | app.push(deploysScreen(session, app.ctx, of)); | |
| 97 | await settleApp(app); | |
| 98 | const frames = [app.screen.toText()]; | |
| 99 | const press = async (...keys: string[]) => { | |
| 100 | for (const k of keys) { | |
| 101 | app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 102 | await settleApp(app); | |
| 103 | frames.push(app.screen.toText()); | |
| 104 | } | |
outdated
Add an agent-admin case beside the reader and guest, plus an assertion that the |
||
| 105 | }; | |
| 106 | const type = async (text: string) => { | |
| 107 | for (const ch of text) { | |
| 108 | app.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false }); | |
| 109 | frames.push(app.screen.toText()); | |
| 110 | } | |
| 111 | await settleApp(app); | |
| 112 | frames.push(app.screen.toText()); | |
| 113 | }; | |
| 114 | return { app, session, frames, press, type, text: () => app.screen.toText() }; | |
| 115 | }; | |
| 116 | ||
| 117 | test("a reader and a guest are not offered i", async () => { | |
| 118 | for (const who of [bob, null]) { | |
| 119 | const t = await drive(who); | |
| 120 | expect(t.text()).not.toContain("prompt source"); | |
| 121 | await t.press("i"); | |
| 122 | expect(t.text()).not.toContain("Connect Instagram"); | |
| 123 | expect(t.text()).not.toContain("No account connected"); | |
| 124 | t.app.stop(); | |
| 125 | } | |
| 126 | }); | |
| 127 | ||
| 128 | test("an agent admin is not offered i and it does nothing", async () => { | |
| 129 | const t = await drive(bot, repo); | |
| 130 | await t.press("i"); | |
| 131 | expect(t.text()).not.toContain("No account connected"); | |
| 132 | expect(t.text()).not.toContain("Not yours"); | |
| 133 | expect(t.text()).toContain("production deploys"); | |
| 134 | t.app.stop(); | |
| 135 | }); | |
| 136 | ||
| 137 | test("the key hint shows for the admin only", async () => { | |
| 138 | const hint = async (who: User | null, of: Repo): Promise<boolean> => { | |
| 139 | const t = await drive(who, of); | |
| 140 | const screen = deploysScreen(t.session, t.app.ctx, of); | |
| 141 | await screen.load?.(new AbortController().signal); | |
| 142 | const shown = screen.keys.some((k) => k.keys.includes("i")); | |
| 143 | t.app.stop(); | |
| 144 | return shown; | |
| 145 | }; | |
| 146 | expect(await hint(alice, repo)).toBe(true); | |
| 147 | expect(await hint(bob, repo)).toBe(false); | |
| 148 | expect(await hint(null, repo)).toBe(false); | |
| 149 | expect(await hint(bot, repo)).toBe(false); | |
| 150 | }); | |
| 151 | ||
| 152 | test("the admin connects, fetches now and removes after a confirm; no frame holds the token", async () => { | |
| 153 | const t = await drive(alice); | |
| 154 | await t.press("i"); | |
| 155 | expect(t.text()).toContain("No account connected"); | |
| 156 | ||
| 157 | await t.press("a"); | |
| 158 | await t.type(TOKEN); | |
| 159 | await t.press("enter"); | |
| 160 | expect(t.text()).toContain("Every day at"); | |
| 161 | await t.press("enter"); | |
| 162 | expect(t.text()).toContain("every day at 15:00 UTC"); | |
| 163 | expect(t.text()).toContain("Has not run yet."); | |
| 164 | expect(listPromptSources(forge, alice, repo)).toMatchObject({ ok: true, value: [{ kind: "instagram" }] }); | |
| 165 | ||
| 166 | // Once an account exists the screen does not offer `a`: it does nothing. | |
| 167 | await t.press("a"); | |
| 168 | expect(t.text()).not.toContain("Connect Instagram"); | |
| 169 | ||
| 170 | // Two quick presses ask Instagram once. | |
| 171 | let release: () => void = () => {}; | |
| 172 | hold = new Promise<void>((r) => (release = r)); | |
| 173 | const before = asked; | |
| 174 | t.app.dispatch({ type: "key", key: "f", ctrl: false, alt: false, shift: false }); | |
| 175 | t.app.dispatch({ type: "key", key: "f", ctrl: false, alt: false, shift: false }); | |
| 176 | release(); | |
| 177 | await settleApp(t.app); | |
| 178 | hold = Promise.resolve(); | |
| 179 | expect(asked).toBe(before + 1); | |
| 180 | t.frames.push(t.text()); | |
| 181 | expect(t.text()).toContain("@handle"); | |
| 182 | expect(t.text()).toContain("Last ran"); | |
| 183 | ||
| 184 | // The web's reader sees the same source, with no token in it. | |
| 185 | const seen = listPromptSources(forge, alice, repo); | |
| 186 | expect(seen).toMatchObject({ ok: true, value: [{ kind: "instagram", account: "handle" }] }); | |
| 187 | expect(JSON.stringify(seen)).not.toContain(TOKEN); | |
| 188 | expect(t.text()).toContain("@handle"); | |
| 189 | expect(t.text()).toContain("Last ran"); | |
| 190 | ||
| 191 | await t.press("d"); | |
| 192 | expect(t.text()).toContain("remove @handle? y/n"); | |
| 193 | await t.press("n"); | |
| 194 | expect(listPromptSources(forge, alice, repo)).toMatchObject({ ok: true, value: [{}] }); | |
| 195 | await t.press("d", "y"); | |
| 196 | expect(listPromptSources(forge, alice, repo)).toMatchObject({ ok: true, value: [] }); | |
| 197 | expect(t.text()).toContain("No account connected"); | |
| 198 | ||
| 199 | const kinds = listEvents(forge.db, { repoId: repo.id }).map((e) => e.kind); | |
| 200 | expect(kinds).toContain("prompt.source_added"); | |
| 201 | expect(kinds).toContain("prompt.source_removed"); | |
| 202 | expect(t.frames.some((f) => f.includes(TOKEN))).toBe(false); | |
| 203 | t.app.stop(); | |
| 204 | }); | |
| 205 | ||
| 206 | test("a bad token is refused in the action's words", async () => { | |
| 207 | const t = await drive(alice); | |
| 208 | await t.press("i", "a"); | |
| 209 | await t.type("two words"); | |
| 210 | await t.press("enter", "enter"); | |
| 211 | expect(t.text()).toContain("Paste the long-lived token"); | |
| 212 | t.app.stop(); | |
| 213 | }); | |
| 214 | ||
| 215 | test("a source connected on the web while the screen is open is refused in the page's words", async () => { | |
| 216 | const t = await drive(alice); | |
| 217 | await t.press("i"); | |
| 218 | expect(t.text()).toContain("No account connected"); | |
| 219 | const web = addPromptSource(forge, alice, repo, { kind: "instagram", token: TOKEN, at: "10:00" }); | |
| 220 | expect(web.ok).toBe(true); | |
| 221 | await t.press("a"); | |
| 222 | await t.type(TOKEN); | |
| 223 | await t.press("enter", "enter"); | |
| 224 | expect(t.text()).toContain("An Instagram account is already connected here."); | |
| 225 | const listed = listPromptSources(forge, alice, repo); | |
| 226 | expect(listed).toMatchObject({ ok: true, value: [{ atUtc: "10:00" }] }); | |
| 227 | if (listed.ok) removePromptSource(forge, alice, repo, listed.value[0]!.id); | |
| 228 | t.app.stop(); | |
| 229 | }); |
new above
offline
The refusal is computed twice here, once through
f.user?.kind === "agent"and once throughlisted.ok. ReusemayManagePrompts(f, repo)so there is one gate. This is also the line that needs the agent test.