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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7949-revoking-a-proposal-token-in-the-termina → main 2 commits fork 2 threads · 2 unresolved
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 | ||
| 293 | 293 | the repository) or `revoke` (`id`). |
| 294 | 294 | - **Terminal**: `ctrl+k`, **Proposal tokens**, then the repository as |
| 295 | 295 | `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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Proposal tokens in the terminal: the ones you minted for a repository, with |
| 3 | 3 | * `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 | |
| 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. | |
| 8 | 9 | */ |
| 9 | 10 | |
| 10 | 11 | import { |
| @@ -20,7 +21,7 @@ import { style } from "../term/style.ts"; | ||
| 20 | 21 | import { truncate } from "../term/width.ts"; |
| 21 | 22 | import type { Session } from "../session.ts"; |
| 22 | 23 | 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"; | |
| 24 | 25 | |
| 25 | 26 | /** Ask for `owner/name`, then show its proposal tokens. */ |
| 26 | 27 | export function proposalTokensPrompt(f: Session, ctx: Ctx, parent: Screen): Screen { |
| @@ -91,9 +92,21 @@ export function proposalTokensScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 91 | 92 | if (e.key === "x") { |
| 92 | 93 | const t = items()[state.index]; |
| 93 | 94 | 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 |
||
| 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 | ); | |
| 97 | 110 | return true; |
| 98 | 111 | } |
| 99 | 112 | if (e.key === "n") { |
test/proposal-token-doors.test.ts +2 −2
| @@ -197,7 +197,7 @@ describe("proposal tokens through MCP and the terminal", () => { | ||
| 197 | 197 | |
| 198 | 198 | // The earlier "mine" token is first; the new one is under the second row. |
| 199 | 199 | await h.press("j"); |
| 200 | await h.press("x"); | |
| 200 | await h.press("x", "y"); | |
| 201 | 201 | expect(proposalOf(app.db, row.token_id)).toBeUndefined(); |
| 202 | 202 | expect(h.text()).toContain("terminal-kid: revoked."); |
| 203 | 203 | h.stop(); |
| @@ -213,7 +213,7 @@ describe("proposal tokens through MCP and the terminal", () => { | ||
| 213 | 213 | const rows = app.db.query<{ child: string }, []>("SELECT child FROM proposal_token ORDER BY token_id").all(); |
| 214 | 214 | expect(rows.length).toBeGreaterThan(0); |
| 215 | 215 | // 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"); | |
| 217 | 217 | expect(h.text()).toContain("revoked"); |
| 218 | 218 | expect(app.db.query("SELECT 1 FROM proposal_token").all()).toEqual([]); |
| 219 | 219 | h.stop(); |
test/tui-proposal-tokens.test.ts +51
| @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 6 | 6 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | import { listProposals, repoByName } from "../src/actions/proposal_tokens.ts"; | |
| 9 | 10 | import { createRepo } from "../src/actions/repos.ts"; |
| 10 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 11 | 12 | import { createUser, getUserByName } from "../src/core/users.ts"; |
| @@ -113,6 +114,9 @@ describe("proposal tokens in the terminal", () => { | ||
| 113 | 114 | expect(h.text()).not.toContain("Shown once"); |
| 114 | 115 | |
| 115 | 116 | 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"); | |
| 116 | 120 | expect(h.text()).toContain("kid-one: revoked."); |
| 117 | 121 | expect(h.text()).not.toContain(secret); |
| 118 | 122 | expect(count(app)).toBe(0); |
| @@ -120,6 +124,53 @@ describe("proposal tokens in the terminal", () => { | ||
| 120 | 124 | h.stop(); |
| 121 | 125 | }); |
| 122 | 126 | |
| 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 | ||
| 123 | 174 | test("bad input mints nothing", async () => { |
| 124 | 175 | const h = boot("alice"); |
| 125 | 176 | await h.open(); |
new above
offline
This line is now far over the file's wrap width; rewrap the paragraph.