@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  * Runs the MCP OAuth 2.1 PKCE loopback (the same ceremony `claude mcp add` runs):
5
5
  * opens the browser to the MCP's authorize page, the developer signs in with their
6
6
  * ToT identity + approves, and the loopback catches the code and exchanges it for a
7
- * token cached at ~/.tot/credentials.json. Every later command (`tot checkout`,
7
+ * token cached at ~/.tot/credentials.json. Every later command (`tot clone`,
8
8
  * `tot submit`, …) then runs as that developer with NO re-auth.
9
9
  *
10
10
  * The MCP defaults to the same target `tot submit` talks to (env MCP_BASE_URL /
@@ -25,6 +25,7 @@ import { openBrowser } from "../open.mjs";
25
25
  import { fail } from "../errors.mjs";
26
26
  import { cockpitRecoveryUrl, normalizeEmailHint, redactEmailForHint, emailFromJwt } from "../auth.mjs";
27
27
  import { isInteractive, promptYesNo } from "../prompt.mjs";
28
+ import { versionStamp } from "../mcp.mjs";
28
29
 
29
30
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
30
31
 
@@ -108,7 +109,7 @@ const USAGE = `tot login — sign in to Token of Trust
108
109
  link a later \`tot feedback\` report to your invite + session) — not meant to be
109
110
  typed by hand.
110
111
 
111
- After signing in, run \`tot whoami\` to confirm, then \`tot checkout\` / \`tot submit\`.`;
112
+ After signing in, run \`tot whoami\` to confirm, then \`tot clone\` / \`tot submit\`.`;
112
113
 
113
114
  /**
114
115
  * The core of `tot login`: run the OAuth ceremony (browser loopback, or the
@@ -255,6 +256,10 @@ export async function run(argv, _ctx) {
255
256
  return 0;
256
257
  }
257
258
 
259
+ // Show which CLI/runtime is actually running BEFORE anything else — the first thing
260
+ // a "why did sign-in behave oddly" investigation needs (e.g. a stale shadowing `tot`).
261
+ console.error(versionStamp());
262
+
258
263
  const mcpUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
259
264
 
260
265
  if (args.code) {
@@ -265,7 +270,7 @@ export async function run(argv, _ctx) {
265
270
  cacheTraceId(env, args.traceId);
266
271
  cacheEmailHint(env, args.emailHint);
267
272
  console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
268
- console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
273
+ console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
269
274
  return 0;
270
275
  } catch (e) {
271
276
  console.error(
@@ -283,7 +288,7 @@ export async function run(argv, _ctx) {
283
288
  await loginAndCache(mcpUrl, env, { log: (m) => console.error(m), device: args.device });
284
289
  cacheTraceId(env, args.traceId);
285
290
  console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
286
- console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
291
+ console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
287
292
  return 0;
288
293
  } catch (e) {
289
294
  console.error(
@@ -0,0 +1,424 @@
1
+ /**
2
+ * `tot pr` — see and manage the candidate PRs `tot submit` opens, mirroring
3
+ * `gh pr`.
4
+ *
5
+ * tot pr [list] list your OPEN candidate PRs for this store
6
+ * tot pr view <N|id> show one candidate (by PR number or changeId)
7
+ * tot pr close <N|id> close (reject) a candidate without merging
8
+ *
9
+ * A candidate PR is the reviewable unit `tot submit` creates. By default a
10
+ * re-submit UPDATES your open candidate; `tot submit --new` forks another. This
11
+ * command fills the gap the raw submit loop left — a first-party way to list your
12
+ * open candidates and to close one (candidate close was otherwise gated to
13
+ * version-control apps only).
14
+ *
15
+ * Runs from inside a tenant checkout (it derives the forge repo from the
16
+ * checkout's origin remote, exactly as `tot submit` does) and reads over the MCP
17
+ * `candidate_status` / `candidate_close` tools. Dependency-free (global fetch +
18
+ * `git`).
19
+ *
20
+ * OPERATOR MODE (unit U17). `tot pr list --tenant <t>` lists a tenant's OPEN
21
+ * candidate queue WITHOUT a checkout — the read-only companion to `tot ship --pr <N>
22
+ * --tenant <t>` (U16). It reuses U16's exact transport: the SAME `GET /api/changes`
23
+ * HTTP endpoint + operator-secret Bearer auth (`resolveOperatorSecret`,
24
+ * `normalizeChangesQueue` from ship.mjs). `view`/`close` stay developer-only.
25
+ */
26
+ import { execFileSync } from "node:child_process";
27
+ import { createMcpClient } from "../mcp.mjs";
28
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
29
+ import { fail } from "../errors.mjs";
30
+ import { repoNameFromRemote, currentBranch } from "./submit.mjs";
31
+ // U17 reuses U16's operator-secret transport helpers verbatim (same env precedence,
32
+ // same queue normalisation) so the operator `tot pr list` and `tot ship --pr` speak
33
+ // one wire, not two.
34
+ import { resolveOperatorSecret, normalizeChangesQueue } from "./ship.mjs";
35
+ import {
36
+ defaultCandidateStatePath,
37
+ readActiveChangeId,
38
+ clearActiveChangeId,
39
+ } from "../candidate-state.mjs";
40
+
41
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
42
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
43
+ const SUBCOMMANDS = ["list", "view", "close"];
44
+
45
+ /**
46
+ * The storefront-owned, shareable `/preview/<tenant>/pr/<N>` link — NEVER the
47
+ * forge/Gitea `url` (2026-08-18 incident: a raw forge PR URL reached an
48
+ * owner). `candidate_status` (the local-checkout MCP tool) has no
49
+ * `previewUrl` field at all, unlike the operator `GET /api/changes` path — so
50
+ * this constructs it the same way `runPrListOperator`'s caller resolves
51
+ * `storefrontUrl`, from the same env/--url override chain. Pure.
52
+ * @param {string} storefrontUrl
53
+ * @param {string} tenant
54
+ * @param {number|null|undefined} prNumber
55
+ * @returns {string|null}
56
+ */
57
+ export function buildPreviewUrl(storefrontUrl, tenant, prNumber) {
58
+ if (typeof prNumber !== "number" || !tenant) return null;
59
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
60
+ return `${base}/preview/${tenant}/pr/${prNumber}`;
61
+ }
62
+
63
+ const USAGE = `tot pr — see and manage candidate PRs
64
+
65
+ tot pr [list] list your open candidate PRs for this store
66
+ tot pr list --tenant <t> OPERATOR: list a tenant's open candidate PRs (no checkout)
67
+ tot pr view <N|id> show one candidate PR (by PR number or changeId)
68
+ tot pr close <N|id> close (reject) a candidate PR without merging
69
+ tot pr close <N|id> --reason "<why>" record why it was closed (audit note)
70
+ tot pr --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
71
+
72
+ DEVELOPER (default) — run inside your OWN store checkout: lists/manages the
73
+ candidates \`tot submit\` opens. A re-submit updates your open one by default;
74
+ \`tot submit --new\` forks another.
75
+
76
+ OPERATOR — \`tot pr list --tenant <appDomain>\` lists ANY tenant's open candidate
77
+ queue WITHOUT a checkout (the read-only companion to \`tot ship --pr <N> --tenant\`).
78
+ It reads \`GET /api/changes\` with an operator secret:
79
+
80
+ tot pr list --tenant <t> list <t>'s open candidate PRs
81
+ tot pr list --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
82
+ tot pr list --secret <s> operator secret (prefer the env vars below)
83
+
84
+ Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
85
+ GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).`;
86
+
87
+ /** Parse `tot pr` argv into { sub, target, reason, mcp, identity, tenant, url, secret, help }. Pure. */
88
+ export function parsePrArgs(argv) {
89
+ const a = {
90
+ sub: null,
91
+ target: null,
92
+ reason: null,
93
+ mcp: null,
94
+ identity: null,
95
+ tenant: null,
96
+ url: null,
97
+ secret: null,
98
+ help: false,
99
+ };
100
+ const positional = [];
101
+ for (let i = 0; i < argv.length; i++) {
102
+ const t = argv[i];
103
+ if (t === "--mcp") a.mcp = argv[++i];
104
+ else if (t === "--identity") a.identity = argv[++i];
105
+ else if (t === "--reason") a.reason = argv[++i];
106
+ else if (t === "--tenant") a.tenant = argv[++i];
107
+ else if (t === "--url") a.url = argv[++i];
108
+ else if (t === "--secret") a.secret = argv[++i];
109
+ else if (t === "--help" || t === "-h") a.help = true;
110
+ else positional.push(t);
111
+ }
112
+ a.sub = positional[0] || "list";
113
+ a.target = positional[1] ?? null;
114
+ return a;
115
+ }
116
+
117
+ /** Normalize the `candidate_status` list response to a flat candidate array. Pure. */
118
+ export function extractCandidates(result) {
119
+ if (Array.isArray(result)) return result;
120
+ if (result && Array.isArray(result.candidates)) return result.candidates;
121
+ if (result && Array.isArray(result.environments)) return result.environments;
122
+ if (result && typeof result.changeId === "string") return [result];
123
+ return [];
124
+ }
125
+
126
+ /** Find a candidate by PR number (all-digits target) or exact changeId. Pure. */
127
+ export function matchCandidate(candidates, target) {
128
+ if (target == null) return null;
129
+ if (/^\d+$/.test(target)) {
130
+ const n = Number(target);
131
+ return candidates.find((c) => c.prNumber === n) ?? null;
132
+ }
133
+ return candidates.find((c) => c.changeId === target) ?? null;
134
+ }
135
+
136
+ /**
137
+ * One-line candidate summary for `tot pr list` — surfaces branch ↔ PR# ↔ preview
138
+ * URL so a dev sees, at a glance, which git branch each candidate belongs to (u4 —
139
+ * branch-bound candidates) and where its preview lives. ONLY `previewUrl` (the
140
+ * storefront-owned `/preview/<tenant>/pr/<N>` link) — NEVER `url` (the forge/
141
+ * Gitea `html_url`), which must never reach a terminal (2026-08-18 incident:
142
+ * a raw forge PR URL reached an owner). `active` marks the one
143
+ * THIS checkout's branch resolves to. Pure — unit-tested.
144
+ * @param {{prNumber?:number|null, branch?:string|null, changeId:string, state?:string|null,
145
+ * previewUrl?:string|null, url?:string|null}} c
146
+ * @param {{ active?: boolean }} [opts]
147
+ */
148
+ export function formatCandidateLine(c, { active = false } = {}) {
149
+ const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
150
+ const branch = c.branch ? c.branch : "(no branch)";
151
+ const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
152
+ const activePart = active ? " ← active" : "";
153
+ return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
154
+ }
155
+
156
+ // ─── Operator queue listing (unit U17) ──────────────────────────────────────────────
157
+ //
158
+ // The read-only companion to `tot ship --pr <N> --tenant <t>` (U16): list a tenant's
159
+ // OPEN candidate queue WITHOUT a checkout. It reuses U16's exact transport — the SAME
160
+ // `GET /api/changes` endpoint (operator accept queue) + Bearer operator-secret auth
161
+ // (`resolveOperatorSecret` / `normalizeChangesQueue` from ship.mjs) — so a listing and
162
+ // a ship read one wire. NO merge/deploy/side-effect; pure listing.
163
+
164
+ /**
165
+ * One-line summary of an OPEN operator-queue candidate for `tot pr list --tenant`.
166
+ * Two shapes come off `GET /api/changes`:
167
+ * - BUILT candidate (`built:true`, has a `changeId`): PR # / status / changeId /
168
+ * short head sha / preview URL — the fields an operator needs to pick a PR to ship.
169
+ * - NOT-BUILT forge PR (`built:false` / `changeId:null` / `status:"not-built"`): an
170
+ * open PR with no preview yet. Render it DISTINCTLY — no changeId (there is none),
171
+ * a `[not built]` tag, and a build HINT instead of a preview URL — so an operator
172
+ * sees it's listable but must `tot preview build` before it can ship.
173
+ * Adapts `formatCandidateLine`'s house style to the `GET /api/changes` shape (`status`
174
+ * not `state`; `previewUrl`; a `headSha` to short-render). Pure — unit-tested.
175
+ * @param {{prNumber?:number|null, status?:string|null, changeId?:string|null,
176
+ * headSha?:string|null, previewUrl?:string|null, built?:boolean}} c
177
+ * @returns {string}
178
+ */
179
+ export function formatOperatorCandidateLine(c) {
180
+ const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
181
+ const head = c.headSha ? c.headSha.slice(0, 8) : "(no head)";
182
+ // A not-built PR: no changeId, so route the operator to build it first.
183
+ const notBuilt = c.built === false || (!c.changeId && c.status === "not-built");
184
+ if (notBuilt) {
185
+ const hint =
186
+ typeof c.prNumber === "number"
187
+ ? ` → tot preview build --pr ${c.prNumber}`
188
+ : " → tot preview build";
189
+ return ` PR ${pr} [not built] ${head}${hint}`;
190
+ }
191
+ const status = c.status ?? "?";
192
+ const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
193
+ return ` PR ${pr} [${status}] ${c.changeId} ${head}${urlPart}`;
194
+ }
195
+
196
+ /**
197
+ * Sort an OPEN candidate queue by PR number DESC (newest PR first); candidates
198
+ * without a PR number sort last, then stably by changeId. Pure — unit-tested.
199
+ * @param {ReturnType<typeof normalizeChangesQueue>} changes
200
+ */
201
+ export function sortQueueByPrDesc(changes) {
202
+ return [...(changes || [])].sort((a, b) => {
203
+ const ap = typeof a.prNumber === "number" ? a.prNumber : -Infinity;
204
+ const bp = typeof b.prNumber === "number" ? b.prNumber : -Infinity;
205
+ if (ap !== bp) return bp - ap;
206
+ return String(a.changeId).localeCompare(String(b.changeId));
207
+ });
208
+ }
209
+
210
+ /**
211
+ * The OPERATOR `tot pr list --tenant <t>` flow: fetch the tenant's OPEN candidate
212
+ * queue over `GET /api/changes` (Bearer operator secret + `X-Tot-Owner` +
213
+ * `x-tot-capability: ship-on-behalf`, mirroring U16) and print each candidate one per
214
+ * line, sorted by PR number desc. Fail-closed: no secret → honest refusal (exit 2)
215
+ * BEFORE any network. `fetch` is injected so it's unit-tested with no live network.
216
+ *
217
+ * @param {{ tenant:string, secret:string, storefrontUrl?:string|null }} params
218
+ * @param {{ fetch?:typeof fetch }} [deps]
219
+ * @returns {Promise<number>} process exit code
220
+ */
221
+ export async function runPrListOperator({ tenant, secret, storefrontUrl = null }, deps = {}) {
222
+ const fetchImpl = deps.fetch || globalThis.fetch;
223
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
224
+
225
+ if (!secret) {
226
+ console.error(
227
+ fail(
228
+ "listing a tenant's queue is an OPERATOR action — it needs an operator secret",
229
+ "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
230
+ ),
231
+ );
232
+ return 2;
233
+ }
234
+
235
+ const authHeaders = {
236
+ authorization: `Bearer ${secret}`,
237
+ "x-tot-owner": tenant,
238
+ "x-tot-capability": "ship-on-behalf",
239
+ };
240
+
241
+ let res;
242
+ try {
243
+ res = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
244
+ } catch (e) {
245
+ console.error(
246
+ fail(`couldn't reach the candidate queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
247
+ );
248
+ return 1;
249
+ }
250
+
251
+ let data = {};
252
+ try {
253
+ data = await res.json();
254
+ } catch {
255
+ /* non-JSON / empty body */
256
+ }
257
+ if (!res.ok) {
258
+ const msg = data?.error || `HTTP ${res.status}`;
259
+ console.error(
260
+ fail(
261
+ `the candidate queue refused the request: ${msg}`,
262
+ res.status === 401 || res.status === 403
263
+ ? "check the operator secret and that it's authorised for this tenant"
264
+ : "check --tenant / --url, then re-run",
265
+ ),
266
+ );
267
+ return 1;
268
+ }
269
+
270
+ const changes = sortQueueByPrDesc(normalizeChangesQueue(data));
271
+ if (!changes.length) {
272
+ console.log(`No open candidate PRs for ${tenant}.`);
273
+ return 0;
274
+ }
275
+ console.log(`Open PRs for ${tenant}:`);
276
+ for (const c of changes) {
277
+ console.log(formatOperatorCandidateLine(c));
278
+ }
279
+ return 0;
280
+ }
281
+
282
+ /** @param {string[]} argv @param {any} ctx */
283
+ export async function run(argv, ctx) {
284
+ const env = process.env;
285
+ const args = parsePrArgs(argv);
286
+ if (args.help) {
287
+ console.log(USAGE);
288
+ return 0;
289
+ }
290
+ if (!SUBCOMMANDS.includes(args.sub)) {
291
+ console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
292
+ return 2;
293
+ }
294
+
295
+ // OPERATOR MODE (U17): `--tenant <t>` lists that tenant's OPEN candidate queue with
296
+ // NO checkout, over the same HTTP transport `tot ship --pr` uses. Only `list` has an
297
+ // operator path today — `view`/`close` stay developer-only (checkout-bound).
298
+ if (args.tenant && `${args.tenant}`.trim()) {
299
+ if (args.sub !== "list") {
300
+ console.error(
301
+ fail(
302
+ `\`tot pr ${args.sub} --tenant\` isn't wired — only \`tot pr list --tenant\` has an operator path`,
303
+ "use `tot pr list --tenant <t>` to list, then act from a checkout",
304
+ ),
305
+ );
306
+ return 2;
307
+ }
308
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
309
+ return await runPrListOperator({
310
+ tenant: `${args.tenant}`.trim(),
311
+ secret: resolveOperatorSecret(args.secret, env),
312
+ storefrontUrl,
313
+ });
314
+ }
315
+
316
+ if (ctx.mode !== "checkout") {
317
+ console.error(
318
+ fail(
319
+ "`tot pr` runs from inside a tenant checkout",
320
+ "tot clone <tenant> <dir> (then `cd` in and re-run), or `tot pr list --tenant <t>` for operator mode",
321
+ ),
322
+ );
323
+ return 2;
324
+ }
325
+ if ((args.sub === "view" || args.sub === "close") && !args.target) {
326
+ console.error(fail(`\`tot pr ${args.sub}\` needs a PR number or changeId`, `tot pr ${args.sub} <N>`));
327
+ return 2;
328
+ }
329
+
330
+ const workspace = ctx.workspacePath;
331
+ const tenant = ctx.tenant;
332
+ const gitSafe = (cargs) => {
333
+ try {
334
+ return execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
335
+ } catch {
336
+ return "";
337
+ }
338
+ };
339
+ const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
340
+ if (!repo) {
341
+ console.error(
342
+ fail("couldn't derive the forge repo from this checkout's remote", "run this from a `tot clone`d store"),
343
+ );
344
+ return 1;
345
+ }
346
+
347
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
348
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
349
+ const statePath = defaultCandidateStatePath(env);
350
+ // Branch-bound (u4): the active-pointer namespace is scoped to the current git
351
+ // branch, so the "← active" marker reflects THIS branch's candidate.
352
+ const scope = { mcpUrl: baseUrl, repo, branch: currentBranch(gitSafe) };
353
+ const client = createMcpClient(baseUrl);
354
+ try {
355
+ const session = await establishSession(client, { env, prefer: args.identity || undefined });
356
+ void session;
357
+ // Bind the active tenant so candidate_status/close read the right scope.
358
+ await client.callTool("client_switch", { tenant });
359
+
360
+ const candidates = extractCandidates(await client.callTool("candidate_status", { repo }));
361
+
362
+ if (args.sub === "list") {
363
+ if (!candidates.length) {
364
+ console.log(`No open candidate PRs for ${repo}. Run \`tot submit\` to open one.`);
365
+ return 0;
366
+ }
367
+ const active = readActiveChangeId(statePath, scope);
368
+ console.log(`Open candidate PRs for ${repo}:`);
369
+ for (const c of candidates) {
370
+ const previewUrl = c.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, c.prNumber);
371
+ console.log(formatCandidateLine({ ...c, previewUrl }, { active: !!active && c.changeId === active }));
372
+ }
373
+ return 0;
374
+ }
375
+
376
+ const match = matchCandidate(candidates, args.target);
377
+ if (!match) {
378
+ console.error(fail(`no open candidate matches "${args.target}"`, "tot pr list (to see your open candidates)"));
379
+ return 1;
380
+ }
381
+
382
+ if (args.sub === "view") {
383
+ const active = readActiveChangeId(statePath, scope);
384
+ console.log(`PR ${typeof match.prNumber === "number" ? `#${match.prNumber}` : "#—"} — ${match.changeId}${match.changeId === active ? " (active)" : ""}`);
385
+ console.log(` state: ${match.state ?? "?"}`);
386
+ if (match.branch) console.log(` branch: ${match.branch}`);
387
+ if (match.headSha) console.log(` head: ${match.headSha}`);
388
+ if (match.baseSha) console.log(` base: ${match.baseSha}`);
389
+ console.log(` mergeable (forge): ${match.mergeable ?? "?"}`);
390
+ // ONLY the storefront-owned preview link -- never the raw forge/Gitea `url`.
391
+ const previewUrl = match.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, match.prNumber);
392
+ if (previewUrl) console.log(` ${previewUrl}`);
393
+ return 0;
394
+ }
395
+
396
+ // close
397
+ await client.callTool("candidate_close", {
398
+ repo,
399
+ changeId: match.changeId,
400
+ ...(args.reason ? { reason: args.reason } : {}),
401
+ });
402
+ console.log(`✓ closed candidate ${match.changeId}${typeof match.prNumber === "number" ? ` (PR #${match.prNumber})` : ""}.`);
403
+ // If we just closed the remembered active candidate, forget it so the next
404
+ // plain `tot submit` starts a fresh one rather than resurrecting this handle.
405
+ if (readActiveChangeId(statePath, scope) === match.changeId) {
406
+ try {
407
+ clearActiveChangeId(statePath, scope);
408
+ } catch { /* best-effort */ }
409
+ }
410
+ return 0;
411
+ } catch (e) {
412
+ if (e instanceof AuthUnavailableError) {
413
+ console.error(fail("sign in to manage candidate PRs", e.hint || "run `tot login`, then re-run"));
414
+ return 1;
415
+ }
416
+ console.error(
417
+ fail(
418
+ `couldn't reach the candidate service: ${String(e?.message || e)}`,
419
+ "check your connection and that you're signed in, then re-run",
420
+ ),
421
+ );
422
+ return 1;
423
+ }
424
+ }