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

Revoking a proposal token in the terminal asks first: `x` names the child, `y` revokes, `n` or esc keeps it #7999

Merged opened this

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

Merged

mason/forge:mason/7949-revoking-a-proposal-token-in-the-termina → main 2 commits fork 2 threads · 2 unresolved

  • Merged. mason/7949-revoking-a-proposal-token-in-the-termina was merged into main as d0cc22d.

Review

Sign in to review this change.
4 files changed +77 −11
docs/FOLLOWING.md +3 −1
@@ -293,4 +293,6 @@ The check cell's memory is on the same page under **Check cell**, and in the ter
293293 the repository) or `revoke` (`id`).
294294 - **Terminal**: `ctrl+k`, **Proposal tokens**, then the repository as
295295 `owner/name`. `n` mints (the child's name, then its https address) and shows
296 the secret once on its own screen; `x` revokes the one under the cursor.
296 the secret once on its own screen; `x` asks before it revokes the one under
297 the cursor ("Revoke the token for <child>?", Keep selected): `y` revokes,
298 `n`, esc or enter keeps it.
src/tui/screens/proposal_tokens.ts +21 −8
@@ -1,10 +1,11 @@
11 /**
22 * Proposal tokens in the terminal: the ones you minted for a repository, with
33 * `n` to mint (a child's name, then where it lives) and `x` to revoke the one
4 * under the cursor. The secret is shown once, on a screen of its own. Every
5 * rule is the action's (`actions/proposal_tokens.ts`), the same as the API's
6 * and MCP's: a repository you do not write is "not visible to you", and
7 * nothing is listed or changed.
4 * under the cursor, after a confirm that starts on Keep (`y` revokes, `n` or
outdated

This line is now far over the file's wrap width; rewrap the paragraph.

5 * esc keeps it). The secret is shown once, on a screen of its own. Every rule
6 * is the action's (`actions/proposal_tokens.ts`), the same as the API's and
7 * MCP's: a repository you do not write is "not visible to you", and nothing
8 * is listed or changed.
89 */
910
1011 import {
@@ -20,7 +21,7 @@ import { style } from "../term/style.ts";
2021 import { truncate } from "../term/width.ts";
2122 import type { Session } from "../session.ts";
2223 import { fill, len, split } from "../ui/layout.ts";
23 import { ListState, drawList, later, promptScreen, type ListRow } from "./common.ts";
24 import { ListState, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts";
2425
2526 /** Ask for `owner/name`, then show its proposal tokens. */
2627 export function proposalTokensPrompt(f: Session, ctx: Ctx, parent: Screen): Screen {
@@ -91,9 +92,21 @@ export function proposalTokensScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
9192 if (e.key === "x") {
9293 const t = items()[state.index];
9394 if (t === undefined) return false;
94 const done = revokeProposal(f.app, f.user, repo, t.id);
95 note = done.ok ? `${t.child}: revoked.` : done.error.message;
96 ctx.invalidate();
95 ctx.push(
96 confirmScreen(ctx, screen, {
97 title: `Revoke the token for ${t.child}?`,
98 body: [`${t.child} can no longer propose here until it is handed a new token.`],
99 actions: [{ label: "Revoke", variant: "danger" }, { label: "Keep", variant: "ghost" }],
outdated

Add selected: 1 so Keep is the default, as the forge's other destructive confirms do. Enter currently revokes.

100 selected: 1,
101 yesNo: true,
102 onChoose: (i) => {
103 if (i !== 0) return;
104 const done = revokeProposal(f.app, f.user, repo, t.id);
105 note = done.ok ? `${t.child}: revoked.` : done.error.message;
106 ctx.invalidate();
107 },
108 }),
109 );
97110 return true;
98111 }
99112 if (e.key === "n") {
test/proposal-token-doors.test.ts +2 −2
@@ -197,7 +197,7 @@ describe("proposal tokens through MCP and the terminal", () => {
197197
198198 // The earlier "mine" token is first; the new one is under the second row.
199199 await h.press("j");
200 await h.press("x");
200 await h.press("x", "y");
201201 expect(proposalOf(app.db, row.token_id)).toBeUndefined();
202202 expect(h.text()).toContain("terminal-kid: revoked.");
203203 h.stop();
@@ -213,7 +213,7 @@ describe("proposal tokens through MCP and the terminal", () => {
213213 const rows = app.db.query<{ child: string }, []>("SELECT child FROM proposal_token ORDER BY token_id").all();
214214 expect(rows.length).toBeGreaterThan(0);
215215 // Revoke every row: the cursor stays on the first.
216 for (let i = 0; i < rows.length; i++) await h.press("x");
216 for (let i = 0; i < rows.length; i++) await h.press("x", "y");
217217 expect(h.text()).toContain("revoked");
218218 expect(app.db.query("SELECT 1 FROM proposal_token").all()).toEqual([]);
219219 h.stop();
test/tui-proposal-tokens.test.ts +51
@@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
66 import { mkdtemp, rm } from "node:fs/promises";
77 import { tmpdir } from "node:os";
88 import { join } from "node:path";
9 import { listProposals, repoByName } from "../src/actions/proposal_tokens.ts";
910 import { createRepo } from "../src/actions/repos.ts";
1011 import { createApp, type App } from "../src/app.ts";
1112 import { createUser, getUserByName } from "../src/core/users.ts";
@@ -113,6 +114,9 @@ describe("proposal tokens in the terminal", () => {
113114 expect(h.text()).not.toContain("Shown once");
114115
115116 await h.press("x");
117 expect(h.text()).toContain("Revoke the token for kid-one?");
118 expect(count(app)).toBe(1);
119 await h.press("y");
116120 expect(h.text()).toContain("kid-one: revoked.");
117121 expect(h.text()).not.toContain(secret);
118122 expect(count(app)).toBe(0);
@@ -120,6 +124,53 @@ describe("proposal tokens in the terminal", () => {
120124 h.stop();
121125 });
122126
127 test("x asks first: n, esc or enter keeps the token listed", async () => {
128 const h = boot("alice");
129 await h.open();
130 await h.press("n");
131 await h.type("kid-two");
132 await h.press("enter");
133 await h.type("https://kid-two.example.com/");
134 await h.press("enter");
135 await h.press("esc");
136
137 const listed = () => {
138 const r = listProposals(app, getUserByName(app.db, "alice"), repoByName(app, "alice/lib"));
139 return r.ok ? r.value.map((t) => t.child) : [];
140 };
141 expect(listed()).toEqual(["kid-two"]);
142
143 await h.press("x");
144 expect(h.text()).toContain("Revoke the token for kid-two?");
145 expect(h.text()).toContain("kid-two can no longer propose here");
146 await h.press("n");
147 expect(h.text()).not.toContain("Revoke the token for");
148 expect(h.text()).not.toContain("revoked.");
149 expect(listed()).toEqual(["kid-two"]);
150
151 await h.press("x", "esc");
152 expect(listed()).toEqual(["kid-two"]);
153
154 // The dialog starts on Keep: a stray enter revokes nothing.
155 await h.press("x", "enter");
156 expect(h.text()).not.toContain("Revoke the token for");
157 expect(listed()).toEqual(["kid-two"]);
158
159 await h.press("x", "y");
160 expect(h.text()).toContain("kid-two: revoked.");
161 expect(listed()).toEqual([]);
162 h.stop();
163 });
164
165 test("x on an empty list asks nothing", async () => {
166 const h = boot("alice");
167 await h.open();
168 expect(h.text()).toContain("None yet");
169 await h.press("x");
170 expect(h.text()).not.toContain("Revoke the token for");
171 h.stop();
172 });
173
123174 test("bad input mints nothing", async () => {
124175 const h = boot("alice");
125176 await h.open();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs