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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8032-an-admin-stops-running-a-repository-as-a → main 2 commits fork 1 thread · 1 unresolved
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 | ||
| 452 | 452 | - 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`. |
| 453 | 453 | - 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`. |
| 454 | 454 | - 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. | |
| 455 | 456 | - 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'). |
| 456 | 457 | - 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`). |
| 457 | 458 | - [](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 @@ | ||
| 46 | 46 | * |
| 47 | 47 | * The admin who is a person also gets `i`: the app's prompt source, the |
| 48 | 48 | * 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. | |
| 49 | 52 | */ |
| 50 | 53 | |
| 51 | 54 | import { |
| @@ -54,12 +57,14 @@ import { | ||
| 54 | 57 | appPullLines, |
| 55 | 58 | appSideLines, |
| 56 | 59 | appView, |
| 60 | disableApp, | |
| 57 | 61 | appVisitors, |
| 58 | 62 | redeployEnv, |
| 59 | 63 | stopEnv, |
| 60 | 64 | type AppPreview, |
| 61 | 65 | type AppPullLine, |
| 62 | 66 | } from "../../actions/apps.ts"; |
| 67 | import { STOP_APP } from "../../web/danger_words.ts"; | |
| 63 | 68 | import { envPhrase } from "../../apps/env_phrase.ts"; |
| 64 | 69 | import { |
| 65 | 70 | addAppDomain, |
| @@ -86,7 +91,7 @@ import { listRepoRuns } from "../../actions/runs.ts"; | ||
| 86 | 91 | import { carriedByRuns, carriesText } from "../../core/carried.ts"; |
| 87 | 92 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 88 | 93 | 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"; | |
| 90 | 95 | import type { Knock } from "../../contract/probe.ts"; |
| 91 | 96 | import { probeApp, type ProbeRow } from "../../contract/probes.ts"; |
| 92 | 97 | import type { Run } from "../../core/runs.ts"; |
| @@ -203,6 +208,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 203 | 208 | let production: Production = { state: "", redeploy: false, stop: false }; |
| 204 | 209 | let members = false; |
| 205 | 210 | let prompts = false; |
| 211 | let stoppable = false; | |
| 212 | let isApp = true; | |
| 206 | 213 | /** The app's domains, or undefined for anyone `listAppDomains` refuses. */ |
| 207 | 214 | let domains: AppDomainView[] | undefined; |
| 208 | 215 | |
| @@ -233,6 +240,8 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 233 | 240 | const listed = listAppDomains(f.app, f.user, repo); |
| 234 | 241 | domains = listed.ok ? listed.value : undefined; |
| 235 | 242 | 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); | |
| 236 | 245 | loaded = true; |
| 237 | 246 | ctx.invalidate(); |
| 238 | 247 | }; |
| @@ -296,6 +305,26 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 296 | 305 | return p === undefined ? undefined : { p, stop: p.env.state !== "stopped" }; |
| 297 | 306 | }; |
| 298 | 307 | |
| 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( | |
| 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 | ||
| 299 | 328 | const screen: Screen = { |
| 300 | 329 | title: "Deploys", |
| 301 | 330 | get keys() { |
| @@ -317,6 +346,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 317 | 346 | ...(members ? [binding(["m"], "members")] : []), |
| 318 | 347 | ...(domains !== undefined ? [binding(["n"], "the app's own domains")] : []), |
| 319 | 348 | ...(prompts ? [binding(["i"], "prompt source (Instagram)")] : []), |
| 349 | ...(stoppable ? [binding(["X"], STOP_APP.toLowerCase())] : []), | |
| 320 | 350 | ]; |
| 321 | 351 | }, |
| 322 | 352 | load: async () => read(), |
| @@ -392,7 +422,9 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 392 | 422 | emptyState(s, body!, c, { |
| 393 | 423 | title: loaded ? "No deploys" : "Reading…", |
| 394 | 424 | 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." | |
| 396 | 428 | : "Reading the deploys", |
| 397 | 429 | }); |
| 398 | 430 | return; |
| @@ -474,6 +506,10 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 474 | 506 | ctx.push(promptsScreen(f, ctx, repo)); |
| 475 | 507 | return true; |
| 476 | 508 | } |
| 509 | if (e.key === "X" && stoppable) { | |
| 510 | stopApp(); | |
| 511 | return true; | |
| 512 | } | |
| 477 | 513 | return false; |
| 478 | 514 | }, |
| 479 | 515 | }; |
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 | }); |
new above
offline
Add the README bullet for X, as for i, and a test for the agent exclusion on the stoppable line.