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

Merged opened this

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

Merged

scout/forge:scout/7465-the-terminal-s-deploys-screen-opens-an-a → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/7465-the-terminal-s-deploys-screen-opens-an-a was merged into main as aee3c18.

Review

Sign in to review this change.
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
418418 - [`docs/SENTINEL.md`](docs/SENTINEL.md) — the forge hunting its own faults; [`docs/HUNT.md`](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`).
419419 - 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`).
420420 - 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).
421422 - 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').
422423 - [`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.
423424 - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [`docs/FLOW.md`](docs/FLOW.md).
src/tui/screens/deploys.ts +11
@@ -32,6 +32,9 @@
3232 * — `addAppDomain`, `checkAppDomain` and `removeAppDomain`, the page's and
3333 * MCP `app_domains`' actions. `listAppDomains` is the admin gate: anyone it
3434 * 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`).
3538 */
3639
3740 import {
@@ -89,6 +92,7 @@ import {
8992 } from "./common.ts";
9093 import { RunList, openRunLog, runRows } from "./runlist.ts";
9194 import { appMembersScreen } from "./appmembers.ts";
95 import { mayManagePrompts, promptsScreen } from "./prompts.ts";
9296
9397 const LIMIT = 50;
9498
@@ -139,6 +143,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
139143 let pulls: AppPullLine[] = [];
140144 let production: Production = { state: "", redeploy: false, stop: false };
141145 let members = false;
146 let prompts = false;
142147 /** The app's domains, or undefined for anyone `listAppDomains` refuses. */
143148 let domains: AppDomainView[] | undefined;
144149
@@ -164,6 +169,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
164169 canWrite(f.db, repo, f.user);
165170 const listed = listAppDomains(f.app, f.user, repo);
166171 domains = listed.ok ? listed.value : undefined;
172 prompts = mayManagePrompts(f, repo);
167173 loaded = true;
168174 ctx.invalidate();
169175 };
@@ -219,6 +225,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
219225 ...(mayProbe ? [binding(["p"], "probe the running app's doors")] : []),
220226 ...(members ? [binding(["m"], "members")] : []),
221227 ...(domains !== undefined ? [binding(["n"], "the app's own domains")] : []),
228 ...(prompts ? [binding(["i"], "prompt source (Instagram)")] : []),
222229 ];
223230 },
224231 load: async () => read(),
@@ -318,6 +325,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
318325 ctx.push(domainsScreen(f, ctx, repo, read));
319326 return true;
320327 }
328 if (e.key === "i" && prompts) {
329 ctx.push(promptsScreen(f, ctx, repo));
330 return true;
331 }
321332 return false;
322333 },
323334 };
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)}.`,

The refusal is computed twice here, once through f.user?.kind === "agent" and once through listed.ok. Reuse mayManagePrompts(f, repo) so there is one gate. This is also the line that needs the agent test.

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 i key hint shows for the admin.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs