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

The terminal's Deploys screen redeploys or stops the selected preview with D and S, after a one-line confirm, as it does production #7995

Merged opened this

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

Merged

scout/forge:scout/7983-the-terminal-s-deploys-screen-redeploys → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/7983-the-terminal-s-deploys-screen-redeploys was merged into main as eec8533.

Review

Sign in to review this change.
2 files changed +250 −34
src/tui/screens/deploys.ts +72 −34
@@ -23,6 +23,10 @@
2323 * (`env_words.ts`). A stopped production offers only `D`; a reader sees
2424 * neither key.
2525 *
26 * With a preview selected, `D` and `S` do the same to that pull's preview
27 * (`redeploy #N's preview? y/n`), the env name read from its `appPreviews`
28 * row; `S` is offered only while it runs, and a reader sees neither.
29 *
2630 * A writer — whom the MCP tool `probe` lets knock — gets `p`: the same
2731 * `probeApp` against the running production app, every door tried as
2832 * nobody, a user and an admin, read-only, one line per door.
@@ -73,6 +77,7 @@ import {
7377 domainStateWord,
7478 recordsInWords,
7579 } from "../../actions/domain_words.ts";
80 import type { EnvName } from "../../apps/data.ts";
7681 import { envLabel, envRefusalText, redeployedText, stoppedText } from "../../actions/env_words.ts";
7782 import { trendLine, visitorsLine } from "../../apps/vitals.ts";
7883 import { egressModeSentence, undeclaredHosts } from "../../apps/egress_report.ts";
@@ -237,50 +242,77 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
237242 // The MCP tool's gate, read once: only a writer is offered the knock.
238243 const mayProbe = canWrite(f.db, repo, f.user);
239244
240 const confirm = (verb: "Redeploy" | "Stop", act: () => Promise<{ text: string; refused: boolean }>): void => {
245 /** Redeploy or stop `env` and say the answer in the page's words; one path for production and previews. */
246 const act = async (verb: "Redeploy" | "Stop", env: EnvName): Promise<{ text: string; refused: boolean }> => {
247 if (verb === "Redeploy") {
248 const queued = await redeployEnv(f.app, f.user, repo, env);
249 return queued.ok
250 ? { text: redeployedText(env, queued.value), refused: false }
251 : { text: envRefusalText(queued.error), refused: true };
252 }
253 const stopped = await stopEnv(f.app, f.user, repo, env);
254 return stopped.ok
255 ? { text: stoppedText(env), refused: false }
256 : { text: envRefusalText(stopped.error), refused: true };
257 };
258
259 /**
260 * Production asks with buttons; a preview asks `redeploy #N's preview? y/n`.
261 * Either way the action gates again and a refusal toasts in red.
262 */
263 const confirm = (verb: "Redeploy" | "Stop", env: EnvName, pull?: number): void => {
264 const go = (): void =>
265 runAsync(ctx, async () => {
266 const answer = await act(verb, env);
267 ctx.toast(answer.text, answer.refused ? "danger" : undefined);
268 read();
269 });
241270 ctx.push(
242 confirmScreen(ctx, screen, {
243 title: `${verb} ${envLabel("production")}`,
244 body: [`${verb} ${envLabel("production")} of ${repo.ownerName}/${repo.name}?`],
245 actions: [{ label: "Cancel", variant: "ghost" }, { label: verb }],
246 selected: 1,
247 onChoose: (choice) => {
248 if (choice !== 1) return;
249 runAsync(ctx, async () => {
250 const answer = await act();
251 ctx.toast(answer.text, answer.refused ? "danger" : undefined);
252 read();
253 });
254 },
255 }),
271 confirmScreen(
272 ctx,
273 screen,
274 pull === undefined
275 ? {
276 title: `${verb} ${envLabel(env)}`,
277 body: [`${verb} ${envLabel(env)} of ${repo.ownerName}/${repo.name}?`],
278 actions: [{ label: "Cancel", variant: "ghost" }, { label: verb }],
279 selected: 1,
280 onChoose: (choice) => choice === 1 && go(),
281 }
282 : {
283 title: `${verb} preview`,
outdated

Silent return on a bad env name leaves the person with a keypress that did nothing; say why or let the action refuse. Also the answer-building below duplicates the production redeploy/stop code above; share it.

284 body: [`${verb.toLowerCase()} #${pull}'s preview? y/n`],
285 actions: [{ label: verb }, { label: "Cancel", variant: "ghost" }],
286 yesNo: true,
287 onChoose: (choice) => choice === 0 && go(),
288 },
289 ),
256290 );
257291 };
258292
259 const redeploy = (): void =>
260 confirm("Redeploy", async () => {
261 const queued = await redeployEnv(f.app, f.user, repo, "production");
262 return queued.ok
263 ? { text: redeployedText("production", queued.value), refused: false }
264 : { text: envRefusalText(queued.error), refused: true };
265 });
266
267 const stop = (): void =>
268 confirm("Stop", async () => {
269 const stopped = await stopEnv(f.app, f.user, repo, "production");
270 return stopped.ok
271 ? { text: stoppedText("production"), refused: false }
272 : { text: envRefusalText(stopped.error), refused: true };
273 });
293 /** The selected preview and what may be done to it, or undefined outside the group or for a reader. */
294 const previewActs = (): { p: AppPreview; stop: boolean } | undefined => {
295 const p = inPreviews && production.redeploy ? previews?.[previewState.index] : undefined;
296 return p === undefined ? undefined : { p, stop: p.env.state !== "stopped" };
297 };
274298
275299 const screen: Screen = {
276300 title: "Deploys",
277301 get keys() {
302 const preview = previewActs();
278303 return [
279304 binding(["j", "k"], "move"),
280305 binding(["enter"], inPreviews ? "open the pull" : "open the log"),
281306 binding(["r"], "refresh (the open pulls below the deploys: check, preview)"),
282 ...(production.redeploy ? [binding(["D"], "redeploy production")] : []),
283 ...(production.stop ? [binding(["S"], "stop production")] : []),
307 ...(inPreviews
308 ? [
309 ...(preview !== undefined ? [binding(["D"], "redeploy preview")] : []),
310 ...(preview?.stop === true ? [binding(["S"], "stop preview")] : []),
311 ]
312 : [
313 ...(production.redeploy ? [binding(["D"], "redeploy production")] : []),
314 ...(production.stop ? [binding(["S"], "stop production")] : []),
315 ]),
284316 ...(mayProbe ? [binding(["p"], "probe the running app's doors")] : []),
285317 ...(members ? [binding(["m"], "members")] : []),
286318 ...(domains !== undefined ? [binding(["n"], "the app's own domains")] : []),
@@ -412,12 +444,18 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
412444 read();
413445 return true;
414446 }
447 if ((e.key === "D" || e.key === "S") && inPreviews) {
448 const target = previewActs();
449 if (target !== undefined && (e.key === "D" || target.stop))
450 confirm(e.key === "D" ? "Redeploy" : "Stop", target.p.env.env, target.p.number);
451 return true;
452 }
415453 if (e.key === "D" && production.redeploy) {
416 redeploy();
454 confirm("Redeploy", "production");
417455 return true;
418456 }
419457 if (e.key === "S" && production.stop) {
420 stop();
458 confirm("Stop", "production");
421459 return true;
422460 }
423461 if (e.key === "p" && mayProbe) {
test/tui-deploys-preview-act.test.ts added +178
@@ -0,0 +1,178 @@
1 /**
2 * The deploys screen redeploys and stops the selected preview with `D` and
3 * `S` after a `y/n` confirm naming the pull; a reader is offered neither key,
4 * and a stopped preview offers only `D`.
5 */
6
7 import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { signUp } from "../src/actions/users.ts";
13 import { createApp, type App as Forge } from "../src/app.ts";
14 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
15 import { createApp as createAppRecord } from "../src/core/apps.ts";
16 import type { Repo } from "../src/core/repos.ts";
17 import type { Run } from "../src/core/runs.ts";
18 import type { User } from "../src/core/users.ts";
19 import { markVerified } from "../src/core/verify.ts";
20 import { createForgeApp } from "../src/tui/forge.ts";
21 import { deploysScreen } from "../src/tui/screens/deploys.ts";
22 import { headlessTerminal } from "../src/tui/term/terminal.ts";
23 import { seedAdmin } from "./helpers/admin.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25 import { fixtureSlot } from "./helpers/slot.ts";
26 import { settleApp } from "./helpers/tui.ts";
27 import { proveAll } from "./helpers/verified.ts";
28
29 quietLogs();
30
31 let dir: string;
32 let forge: Forge;
33 let runtime: AppsRuntime;
34 let alice: User;
35 let bob: User;
36 let repo: Repo;
37 let previewState: EnvStatus["state"];
38 let deploys: string[];
39 let stops: string[];
40
41 beforeAll(async () => {
42 dir = await mkdtemp(join(tmpdir(), "forge-tui-preview-act-"));
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(forge, { username: "bob", email: "[email protected]", password: "a-long-passphrase" }, { slot: fixtureSlot(forge) });
48 if (!b.ok) throw new Error("signup failed");
49 bob = b.value;
50 proveAll(forge);
51 const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" });
52 if (!r.ok) throw new Error("repo failed");
53 repo = r.value;
54 const committed = await commitToBranch(forge, alice, repo, {
55 message: "Light the lamp",
56 files: [{ path: "README.md", content: "lamp\n" }],
57 });
58 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
59 const made = createAppRecord(forge.db, { repoId: repo.id, slug: "lamp", access: "public" });
60 if (!made.ok) throw new Error(made.error);
61 const id = crypto.randomUUID();
62 forge.db
63 .query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES (?, ?, 7, 'pull', 'Brighter wick', ?)")
64 .run(id, repo.id, alice.id);
65 forge.db
66 .query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', 'wick', 'b', 'h')")
67 .run(id);
68 runtime = new AppsRuntime(forge, { domain: "apps.test", scheme: "https", forgeUrl: "https://forge.test", concurrency: 1 });
69 runtime.status = (() => [
70 { env: "production", state: "running", url: "https://lamp.apps.test", commitOid: "a".repeat(40) } as EnvStatus,
71 { env: "pull-7", state: previewState, url: "https://lamp--7.apps.test", commitOid: "a".repeat(40) } as EnvStatus,
72 ]) as typeof runtime.status;
73 runtime.requestDeploy = ((_record, _repo, env) => {
74 deploys.push(env);
75 return { id: "run-1" } as Run;
76 }) as typeof runtime.requestDeploy;
77 runtime.stopEnv = (async (_record, env) => {
78 stops.push(env);
79 previewState = "stopped";
80 }) as typeof runtime.stopEnv;
81 });
82 afterAll(async () => {
83 await runtime.stop();
84 forge.close?.();
85 await rm(dir, { recursive: true, force: true });
86 });
87 beforeEach(() => {
88 previewState = "running";
89 deploys = [];
90 stops = [];
91 });
92
93 const open = async (who: User) => {
94 const { app, session } = createForgeApp(
95 forge,
96 { sshUsername: who.username, remoteAddress: "127.0.0.1", user: who },
97 { terminal: headlessTerminal(200, 30), exitHooks: false },
98 );
99 app.start();
100 await settleApp(app);
101 const screen = deploysScreen(session, app.ctx, repo);
102 app.push(screen);
103 await settleApp(app);
104 const press = async (...keys: string[]): Promise<void> => {
105 for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
106 await settleApp(app);
107 };
108 return { app, press, keys: () => screen.keys.map((k) => k.help), text: () => app.screen.toText() };
109 };
110
111 test("a writer stops a preview with S and y; the runtime saw that env", async () => {
112 const h = await open(alice);
113 // No deploys, so the cursor is already among the previews.
114 await h.press("j");
115 expect(h.keys()).toContain("redeploy preview");
116 expect(h.keys()).toContain("stop preview");
117 expect(h.keys()).not.toContain("stop production");
118 await h.press("S");
119 expect(h.text()).toContain("stop #7's preview? y/n");
120 await h.press("y");
121 expect(stops).toEqual(["pull-7"]);
122 expect(h.text()).toContain("Stopped preview of #7.");
123 // Stopped: only redeploy is left.
124 expect(h.keys()).toContain("redeploy preview");
125 expect(h.keys()).not.toContain("stop preview");
126 h.app.stop();
127 });
128
129 test("D and y redeploys the preview; n changes nothing", async () => {
130 const h = await open(alice);
131 await h.press("j", "D");
132 expect(h.text()).toContain("redeploy #7's preview? y/n");
133 await h.press("n");
134 expect(deploys).toEqual([]);
135 await h.press("S", "n");
136 expect(stops).toEqual([]);
137 await h.press("D", "y");
138 expect(deploys).toEqual(["pull-7"]);
139 expect(h.text()).toContain("Redeploying preview of #7.");
140 h.app.stop();
141 });
142
143 test("a reader is offered neither key", async () => {
144 const h = await open(bob);
145 await h.press("j");
146 expect(h.keys().filter((k) => /^(redeploy|stop) /.test(k))).toEqual([]);
147 await h.press("D", "S");
148 expect(h.text()).not.toContain("preview? y/n");
149 expect(deploys).toEqual([]);
150 expect(stops).toEqual([]);
151 h.app.stop();
152 });
153
154 test("a refused preview action toasts in red and calls nothing", async () => {
155 forge.db.query("UPDATE email_check SET verified_at = NULL WHERE user_id = ?1").run(alice.id);
156 try {
157 const h = await open(alice);
158 await h.press("j", "D", "y");
159 expect(deploys).toEqual([]);
160 expect(h.text()).toContain("Verify your email first");
161 h.app.stop();
162 } finally {
163 markVerified(forge.db, alice.id, new Date().toISOString());
164 }
165 });
166
167 test("on a deploy row, D and S still act on production", async () => {
168 const h = await open(alice);
169 expect(h.keys()).toContain("redeploy production");
170 expect(h.keys()).not.toContain("redeploy preview");
171 await h.press("D");
172 expect(h.text()).toContain("Redeploy production of alice/lamp?");
173 await h.press("enter");
174 expect(deploys).toEqual(["production"]);
175 await h.press("S", "enter");
176 expect(stops).toEqual(["production"]);
177 h.app.stop();
178 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs