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

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.
Files changed (47) hide show
  1. package/README.md +12 -9
  2. package/bin/tot.mjs +219 -44
  3. package/package.json +7 -2
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +2 -2
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +137 -0
  8. package/src/commands/accept.mjs +736 -0
  9. package/src/commands/app/dev.mjs +7 -3
  10. package/src/commands/app/index.mjs +2 -2
  11. package/src/commands/branches.mjs +297 -0
  12. package/src/commands/cleanup.mjs +269 -0
  13. package/src/commands/clone.mjs +713 -0
  14. package/src/commands/dev.mjs +441 -93
  15. package/src/commands/doctor.mjs +4 -3
  16. package/src/commands/git-credential.mjs +180 -0
  17. package/src/commands/go-live.mjs +486 -0
  18. package/src/commands/grants.mjs +14 -7
  19. package/src/commands/hotfix.mjs +428 -0
  20. package/src/commands/link.mjs +225 -0
  21. package/src/commands/login.mjs +12 -8
  22. package/src/commands/pr.mjs +425 -0
  23. package/src/commands/preview-build.mjs +225 -0
  24. package/src/commands/preview.mjs +80 -0
  25. package/src/commands/retire.mjs +203 -0
  26. package/src/commands/revert.mjs +322 -0
  27. package/src/commands/rollback.mjs +403 -0
  28. package/src/commands/ship.mjs +517 -0
  29. package/src/commands/start.mjs +91 -29
  30. package/src/commands/submit.mjs +1360 -131
  31. package/src/commands/sync.mjs +203 -0
  32. package/src/commands/validate.mjs +11 -5
  33. package/src/commands/whoami.mjs +6 -2
  34. package/src/context.mjs +2 -2
  35. package/src/dev-heartbeat.mjs +2 -1
  36. package/src/errors.mjs +8 -4
  37. package/src/git-credential.mjs +185 -0
  38. package/src/mcp.mjs +6 -1
  39. package/src/no-gitea-links.test.mjs +55 -0
  40. package/src/oauth.mjs +26 -11
  41. package/src/obstacle-beacon.cjs +3 -3
  42. package/src/obstacle.mjs +1 -1
  43. package/src/plan.mjs +262 -0
  44. package/src/sample.mjs +30 -4
  45. package/src/validate.mjs +56 -0
  46. package/src/viewer-session.mjs +118 -0
  47. package/src/commands/checkout.mjs +0 -330
@@ -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
@@ -119,7 +120,7 @@ After signing in, run \`tot whoami\` to confirm, then \`tot checkout\` / \`tot s
119
120
  * without duplicating this.
120
121
  * @returns {Promise<object>} the credentials written to disk.
121
122
  */
122
- export async function loginAndCache(mcpUrl, env = process.env, { log = () => {}, device = false } = {}) {
123
+ export async function loginAndCache(mcpUrl, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}), device = false } = {}) {
123
124
  const path = defaultCredentialsPath(env);
124
125
  const prior = readCredentials(path);
125
126
  const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
@@ -195,11 +196,10 @@ export async function offerSignIn(mcpUrl, env = process.env, {
195
196
  * `log` carries the fingerprint + "waiting for approval" lines to the terminal.
196
197
  * @returns {Promise<object>} the credentials written to disk.
197
198
  */
198
- export async function redeemAndCache(mcpUrl, code, env = process.env, { log = () => {} } = {}) {
199
+ export async function redeemAndCache(mcpUrl, code, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}) } = {}) {
199
200
  const path = defaultCredentialsPath(env);
200
201
  const prior = readCredentials(path);
201
- const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
202
- const creds = await rendezvousLoginFlow({ mcpUrl, clientId, code, log });
202
+ const creds = await rendezvousLoginFlow({ mcpUrl, code, log });
203
203
  const merged = mergeActivityBridge(prior, mcpUrl, creds);
204
204
  writeCredentials(path, merged);
205
205
  return merged;
@@ -255,6 +255,10 @@ export async function run(argv, _ctx) {
255
255
  return 0;
256
256
  }
257
257
 
258
+ // Show which CLI/runtime is actually running BEFORE anything else — the first thing
259
+ // a "why did sign-in behave oddly" investigation needs (e.g. a stale shadowing `tot`).
260
+ console.error(versionStamp());
261
+
258
262
  const mcpUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
259
263
 
260
264
  if (args.code) {
@@ -265,7 +269,7 @@ export async function run(argv, _ctx) {
265
269
  cacheTraceId(env, args.traceId);
266
270
  cacheEmailHint(env, args.emailHint);
267
271
  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.");
272
+ console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
269
273
  return 0;
270
274
  } catch (e) {
271
275
  console.error(
@@ -283,7 +287,7 @@ export async function run(argv, _ctx) {
283
287
  await loginAndCache(mcpUrl, env, { log: (m) => console.error(m), device: args.device });
284
288
  cacheTraceId(env, args.traceId);
285
289
  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.");
290
+ console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
287
291
  return 0;
288
292
  } catch (e) {
289
293
  console.error(
@@ -0,0 +1,425 @@
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
+ "no operator secret — view this tenant's queue from the storefront admin instead.",
229
+ `open https://${tenant}/admin → Publish tab to see the candidate queue and act on it. ` +
230
+ "Operators/CI: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret.",
231
+ ),
232
+ );
233
+ return 2;
234
+ }
235
+
236
+ const authHeaders = {
237
+ authorization: `Bearer ${secret}`,
238
+ "x-tot-owner": tenant,
239
+ "x-tot-capability": "ship-on-behalf",
240
+ };
241
+
242
+ let res;
243
+ try {
244
+ res = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
245
+ } catch (e) {
246
+ console.error(
247
+ fail(`couldn't reach the candidate queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
248
+ );
249
+ return 1;
250
+ }
251
+
252
+ let data = {};
253
+ try {
254
+ data = await res.json();
255
+ } catch {
256
+ /* non-JSON / empty body */
257
+ }
258
+ if (!res.ok) {
259
+ const msg = data?.error || `HTTP ${res.status}`;
260
+ console.error(
261
+ fail(
262
+ `the candidate queue refused the request: ${msg}`,
263
+ res.status === 401 || res.status === 403
264
+ ? "check the operator secret and that it's authorised for this tenant"
265
+ : "check --tenant / --url, then re-run",
266
+ ),
267
+ );
268
+ return 1;
269
+ }
270
+
271
+ const changes = sortQueueByPrDesc(normalizeChangesQueue(data));
272
+ if (!changes.length) {
273
+ console.log(`No open candidate PRs for ${tenant}.`);
274
+ return 0;
275
+ }
276
+ console.log(`Open PRs for ${tenant}:`);
277
+ for (const c of changes) {
278
+ console.log(formatOperatorCandidateLine(c));
279
+ }
280
+ return 0;
281
+ }
282
+
283
+ /** @param {string[]} argv @param {any} ctx */
284
+ export async function run(argv, ctx) {
285
+ const env = process.env;
286
+ const args = parsePrArgs(argv);
287
+ if (args.help) {
288
+ console.log(USAGE);
289
+ return 0;
290
+ }
291
+ if (!SUBCOMMANDS.includes(/** @type {any} */ (args.sub))) {
292
+ console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
293
+ return 2;
294
+ }
295
+
296
+ // OPERATOR MODE (U17): `--tenant <t>` lists that tenant's OPEN candidate queue with
297
+ // NO checkout, over the same HTTP transport `tot ship --pr` uses. Only `list` has an
298
+ // operator path today — `view`/`close` stay developer-only (checkout-bound).
299
+ if (args.tenant && `${args.tenant}`.trim()) {
300
+ if (args.sub !== "list") {
301
+ console.error(
302
+ fail(
303
+ `\`tot pr ${args.sub} --tenant\` isn't wired — only \`tot pr list --tenant\` has an operator path`,
304
+ "use `tot pr list --tenant <t>` to list, then act from a checkout",
305
+ ),
306
+ );
307
+ return 2;
308
+ }
309
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
310
+ return await runPrListOperator({
311
+ tenant: `${args.tenant}`.trim(),
312
+ secret: resolveOperatorSecret(args.secret, env),
313
+ storefrontUrl,
314
+ });
315
+ }
316
+
317
+ if (ctx.mode !== "checkout") {
318
+ console.error(
319
+ fail(
320
+ "`tot pr` runs from inside a tenant checkout",
321
+ "tot clone <tenant> <dir> (then `cd` in and re-run), or `tot pr list --tenant <t>` for operator mode",
322
+ ),
323
+ );
324
+ return 2;
325
+ }
326
+ if ((args.sub === "view" || args.sub === "close") && !args.target) {
327
+ console.error(fail(`\`tot pr ${args.sub}\` needs a PR number or changeId`, `tot pr ${args.sub} <N>`));
328
+ return 2;
329
+ }
330
+
331
+ const workspace = ctx.workspacePath;
332
+ const tenant = ctx.tenant;
333
+ const gitSafe = (cargs) => {
334
+ try {
335
+ return execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
336
+ } catch {
337
+ return "";
338
+ }
339
+ };
340
+ const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
341
+ if (!repo) {
342
+ console.error(
343
+ fail("couldn't derive the forge repo from this checkout's remote", "run this from a `tot clone`d store"),
344
+ );
345
+ return 1;
346
+ }
347
+
348
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
349
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
350
+ const statePath = defaultCandidateStatePath(env);
351
+ // Branch-bound (u4): the active-pointer namespace is scoped to the current git
352
+ // branch, so the "← active" marker reflects THIS branch's candidate.
353
+ const scope = { mcpUrl: baseUrl, repo, branch: currentBranch(gitSafe) };
354
+ const client = createMcpClient(baseUrl);
355
+ try {
356
+ const session = await establishSession(client, { env, prefer: args.identity || undefined });
357
+ void session;
358
+ // Bind the active tenant so candidate_status/close read the right scope.
359
+ await client.callTool("client_switch", { tenant });
360
+
361
+ const candidates = extractCandidates(await client.callTool("candidate_status", { repo }));
362
+
363
+ if (args.sub === "list") {
364
+ if (!candidates.length) {
365
+ console.log(`No open candidate PRs for ${repo}. Run \`tot submit\` to open one.`);
366
+ return 0;
367
+ }
368
+ const active = readActiveChangeId(statePath, scope);
369
+ console.log(`Open candidate PRs for ${repo}:`);
370
+ for (const c of candidates) {
371
+ const previewUrl = c.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, c.prNumber);
372
+ console.log(formatCandidateLine({ ...c, previewUrl }, { active: !!active && c.changeId === active }));
373
+ }
374
+ return 0;
375
+ }
376
+
377
+ const match = matchCandidate(candidates, args.target);
378
+ if (!match) {
379
+ console.error(fail(`no open candidate matches "${args.target}"`, "tot pr list (to see your open candidates)"));
380
+ return 1;
381
+ }
382
+
383
+ if (args.sub === "view") {
384
+ const active = readActiveChangeId(statePath, scope);
385
+ console.log(`PR ${typeof match.prNumber === "number" ? `#${match.prNumber}` : "#—"} — ${match.changeId}${match.changeId === active ? " (active)" : ""}`);
386
+ console.log(` state: ${match.state ?? "?"}`);
387
+ if (match.branch) console.log(` branch: ${match.branch}`);
388
+ if (match.headSha) console.log(` head: ${match.headSha}`);
389
+ if (match.baseSha) console.log(` base: ${match.baseSha}`);
390
+ console.log(` mergeable (forge): ${match.mergeable ?? "?"}`);
391
+ // ONLY the storefront-owned preview link -- never the raw forge/Gitea `url`.
392
+ const previewUrl = match.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, match.prNumber);
393
+ if (previewUrl) console.log(` ${previewUrl}`);
394
+ return 0;
395
+ }
396
+
397
+ // close
398
+ await client.callTool("candidate_close", {
399
+ repo,
400
+ changeId: match.changeId,
401
+ ...(args.reason ? { reason: args.reason } : {}),
402
+ });
403
+ console.log(`✓ closed candidate ${match.changeId}${typeof match.prNumber === "number" ? ` (PR #${match.prNumber})` : ""}.`);
404
+ // If we just closed the remembered active candidate, forget it so the next
405
+ // plain `tot submit` starts a fresh one rather than resurrecting this handle.
406
+ if (readActiveChangeId(statePath, scope) === match.changeId) {
407
+ try {
408
+ clearActiveChangeId(statePath, scope);
409
+ } catch { /* best-effort */ }
410
+ }
411
+ return 0;
412
+ } catch (e) {
413
+ if (e instanceof AuthUnavailableError) {
414
+ console.error(fail("sign in to manage candidate PRs", e.hint || "run `tot login`, then re-run"));
415
+ return 1;
416
+ }
417
+ console.error(
418
+ fail(
419
+ `couldn't reach the candidate service: ${String(e?.message || e)}`,
420
+ "check your connection and that you're signed in, then re-run",
421
+ ),
422
+ );
423
+ return 1;
424
+ }
425
+ }