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

An admin stops running a repository as an app from the terminal's Deploys screen with `X`, after typing its name, as Settings' Stop running as an app does #8038

Merged opened this

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

Merged

scout/forge:scout/8032-an-admin-stops-running-a-repository-as-a → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/8032-an-admin-stops-running-a-repository-as-a was merged into main as f597391.

Review

Sign in to review this change.
3 files changed +180 −2
README.md +1
@@ -452,6 +452,7 @@ This page is the front door. Everything else is a file of its own under
452452 - The `flow` label: ready issues labelled `flow` go first (`readyNow` in `src/core/dependencies.ts` is the one place); when none is ready, builders take other ready work as usual. There is no switch and no state. A writer puts an issue first with the **Flow first** button on the issue and on each row of its list (`POST …/issues/:number/flow`), with `f` on the terminal issue screen, or with MCP `labels` and `action: "add"` (all three make the label on first use, for a writer only); the map's "Ready now" shows labelled work on top, marked `flow`.
453453 - 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`.
454454 - 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).
455 - 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.
455456 - 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').
456457 - 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`).
457458 - [`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.
src/tui/screens/deploys.ts +38 −2
@@ -46,6 +46,9 @@
4646 *
4747 * The admin who is a person also gets `i`: the app's prompt source, the
4848 * Instagram account whose comments become the crew's tell (`prompts.ts`).
49 * That admin also gets `X`, stop running as an app: after typing
50 * `<owner>/<name>` it calls `disableApp`, as Settings does, and the screen
51 * then says the repository is no longer an app.
4952 */
5053
5154 import {
@@ -54,12 +57,14 @@ import {
5457 appPullLines,
5558 appSideLines,
5659 appView,
60 disableApp,
5761 appVisitors,
5862 redeployEnv,
5963 stopEnv,
6064 type AppPreview,
6165 type AppPullLine,
6266 } from "../../actions/apps.ts";
67 import { STOP_APP } from "../../web/danger_words.ts";
6368 import { envPhrase } from "../../apps/env_phrase.ts";
6469 import {
6570 addAppDomain,
@@ -86,7 +91,7 @@ import { listRepoRuns } from "../../actions/runs.ts";
8691 import { carriedByRuns, carriesText } from "../../core/carried.ts";
8792 import { appsRuntime } from "../../apps/runtime.ts";
8893 import { getAppByRepo } from "../../core/apps.ts";
89 import { canWrite, type Repo } from "../../core/repos.ts";
94 import { canAdmin, canWrite, type Repo } from "../../core/repos.ts";
9095 import type { Knock } from "../../contract/probe.ts";
9196 import { probeApp, type ProbeRow } from "../../contract/probes.ts";
9297 import type { Run } from "../../core/runs.ts";
@@ -203,6 +208,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
203208 let production: Production = { state: "", redeploy: false, stop: false };
204209 let members = false;
205210 let prompts = false;
211 let stoppable = false;
212 let isApp = true;
206213 /** The app's domains, or undefined for anyone `listAppDomains` refuses. */
207214 let domains: AppDomainView[] | undefined;
208215
@@ -233,6 +240,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
233240 const listed = listAppDomains(f.app, f.user, repo);
234241 domains = listed.ok ? listed.value : undefined;
235242 prompts = mayManagePrompts(f, repo);
243 isApp = getAppByRepo(f.db, repo.id) !== undefined;
244 stoppable = isApp && f.user !== undefined && f.user.kind !== "agent" && canAdmin(f.db, repo, f.user);
236245 loaded = true;
237246 ctx.invalidate();
238247 };
@@ -296,6 +305,26 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
296305 return p === undefined ? undefined : { p, stop: p.env.state !== "stopped" };
297306 };
298307
308 const name = `${repo.ownerName}/${repo.name}`;
309 /** `X`: type the name, then `disableApp` as Settings does; wrong names change nothing. */
310 const stopApp = (): void => {
311 ctx.push(

Add the README bullet for X, as for i, and a test for the agent exclusion on the stoppable line.

312 promptScreen(ctx, screen, {
313 title: STOP_APP,
314 label: `Type ${name} to confirm`,
315 hint: "All environments stop, data goes; code stays.",
316 placeholder: name,
317 onSubmit: (typed) => {
318 if (typed !== name) return `Type ${name} to confirm.`;
319 const done = disableApp(f.app, f.user, repo);
320 ctx.toast(done.ok ? "no longer an app" : "That app is not there, or it is not yours to stop.", done.ok ? undefined : "danger");
321 read();
322 return undefined;
323 },
324 }),
325 );
326 };
327
299328 const screen: Screen = {
300329 title: "Deploys",
301330 get keys() {
@@ -317,6 +346,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
317346 ...(members ? [binding(["m"], "members")] : []),
318347 ...(domains !== undefined ? [binding(["n"], "the app's own domains")] : []),
319348 ...(prompts ? [binding(["i"], "prompt source (Instagram)")] : []),
349 ...(stoppable ? [binding(["X"], STOP_APP.toLowerCase())] : []),
320350 ];
321351 },
322352 load: async () => read(),
@@ -392,7 +422,9 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
392422 emptyState(s, body!, c, {
393423 title: loaded ? "No deploys" : "Reading…",
394424 body: loaded
395 ? "Nothing has been deployed to production yet."
425 ? isApp
426 ? "Nothing has been deployed to production yet."
427 : "This repository is no longer an app."
396428 : "Reading the deploys",
397429 });
398430 return;
@@ -474,6 +506,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
474506 ctx.push(promptsScreen(f, ctx, repo));
475507 return true;
476508 }
509 if (e.key === "X" && stoppable) {
510 stopApp();
511 return true;
512 }
477513 return false;
478514 },
479515 };
test/tui-deploys-disable.test.ts added +141
@@ -0,0 +1,141 @@
1 /**
2 * The deploys screen stops running a repository as an app (#8032): `X`, after
3 * typing `<owner>/<name>`, calls `disableApp` as Settings does. Only an admin
4 * who is a person is offered it; a wrong name changes nothing. Runs against a
5 * temporary data directory.
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 { createRepo } from "../src/actions/repos.ts";
13 import { addTeamMember, createOrg, createTeam } from "../src/actions/orgs.ts";
14 import { getOrg } from "../src/core/orgs.ts";
15 import { createAgentRow } from "../src/core/agents.ts";
16 import { createUser, getUserById } from "../src/core/users.ts";
17 import { signUp } from "../src/actions/users.ts";
18 import { createApp, type App as Forge } from "../src/app.ts";
19 import { createApp as createAppRecord, getAppByRepo } from "../src/core/apps.ts";
20 import type { Repo } from "../src/core/repos.ts";
21 import type { User } from "../src/core/users.ts";
22 import { createForgeApp } from "../src/tui/forge.ts";
23 import { deploysScreen } from "../src/tui/screens/deploys.ts";
24 import { headlessTerminal } from "../src/tui/term/terminal.ts";
25 import { seedAdmin } from "./helpers/admin.ts";
26 import { quietLogs } from "./helpers/globals.ts";
27 import { fixtureSlot } from "./helpers/slot.ts";
28 import { settleApp } from "./helpers/tui.ts";
29 import { proveAll } from "./helpers/verified.ts";
30
31 quietLogs();
32
33 let dir: string;
34 let forge: Forge;
35 let alice: User;
36 let bob: User;
37 let bot: User;
38 let lamp: Repo;
39 let kiln: Repo;
40
41 beforeAll(async () => {
42 dir = await mkdtemp(join(tmpdir(), "forge-tui-deploys-disable-"));
43 forge = createApp({ dataDir: dir, port: 0 });
44 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
45 if (!a.ok) throw new Error("signup failed");
46 alice = a.value;
47 const b = await signUp(
48 forge,
49 { username: "bob", email: "[email protected]", password: "a-long-passphrase" },
50 { slot: fixtureSlot(forge) },
51 );
52 if (!b.ok) throw new Error("signup failed");
53 bob = b.value;
54 proveAll(forge);
55 // A site-admin agent: disableApp would let it in, the screen must not offer X.
56 const made = await createUser(forge.db, { username: "bot", email: "[email protected]", agent: true, origin: "agent" });
57 if (!made.ok) throw new Error(made.error);
58 createAgentRow(forge.db, { userId: made.value.id, brief: "bot." });
59 forge.db.query("UPDATE user SET is_admin = 1 WHERE id = ?1").run(made.value.id);
60 bot = getUserById(forge.db, made.value.id)!;
61 // Bob writes to kiln, an org's repository, without administering it.
62 if (!createOrg(forge, alice, "acme").ok) throw new Error("org failed");
63 const acme = getOrg(forge.db, "acme")!;
64 const devs = createTeam(forge, alice, acme, "devs", "write");
65 if (!devs.ok || !addTeamMember(forge, alice, acme, devs.value, "bob").ok) throw new Error("team failed");
66 for (const name of ["lamp", "kiln"]) {
67 const r = await createRepo(forge, alice, { name, visibility: "public", ...(name === "kiln" ? { owner: "acme" } : {}) });
68 if (!r.ok) throw new Error("repo failed");
69 const made = createAppRecord(forge.db, { repoId: r.value.id, slug: name, access: "public" });
70 if (!made.ok) throw new Error(made.error);
71 if (name === "lamp") lamp = r.value;
72 else kiln = r.value;
73 }
74 });
75 afterAll(async () => {
76 forge.close?.();
77 await rm(dir, { recursive: true, force: true });
78 });
79
80 const open = async (who: User, of: Repo) => {
81 const { app, session } = createForgeApp(
82 forge,
83 { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who },
84 { terminal: headlessTerminal(200, 30), exitHooks: false },
85 );
86 app.start();
87 await settleApp(app);
88 const toasts: string[] = [];
89 const toast = app.toast.bind(app);
90 app.toast = (text, tone) => {
91 toasts.push(text);
92 toast(text, tone);
93 };
94 const screen = deploysScreen(session, app.ctx, of);
95 app.push(screen);
96 await settleApp(app);
97 const press = async (...keys: string[]): Promise<void> => {
98 for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
99 await settleApp(app);
100 };
101 return { app, press, toasts, keys: () => screen.keys.map((k) => k.help), text: () => app.screen.toText() };
102 };
103
104 test("a wrong name is refused and the app stays", async () => {
105 const h = await open(alice, lamp);
106 expect(h.keys()).toContain("stop running as an app");
107 await h.press("X");
108 expect(h.text()).toContain("code stays.");
109 await h.press(..."alice/lam".split(""), "enter");
110 expect(h.text()).toContain("Type alice/lamp to confirm.");
111 expect(getAppByRepo(forge.db, lamp.id)).toBeDefined();
112 h.app.stop();
113 });
114
115 test("typing the name removes the app record and the screen says so", async () => {
116 const h = await open(alice, lamp);
117 await h.press("X", ..."alice/lamp".split(""), "enter");
118 expect(getAppByRepo(forge.db, lamp.id)).toBeUndefined();
119 expect(h.toasts).toContain("no longer an app");
120 expect(h.text()).toContain("This repository is no longer an app.");
121 expect(h.keys()).not.toContain("stop running as an app");
122 h.app.stop();
123 });
124
125 test("a writer who is not an admin is not offered X", async () => {
126 const h = await open(bob, kiln);
127 expect(h.keys()).not.toContain("stop running as an app");
128 await h.press("X");
129 expect(h.text()).not.toContain("Type acme/kiln");
130 expect(getAppByRepo(forge.db, kiln.id)).toBeDefined();
131 h.app.stop();
132 });
133
134 test("an agent admin is not offered X and it does nothing", async () => {
135 const h = await open(bot, kiln);
136 expect(h.keys()).not.toContain("stop running as an app");
137 await h.press("X");
138 expect(h.text()).not.toContain("Type acme/kiln");
139 expect(getAppByRepo(forge.db, kiln.id)).toBeDefined();
140 h.app.stop();
141 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs