@tokenoftrust/cli 1.4.0 → 1.5.0

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 (54) hide show
  1. package/README.md +5 -0
  2. package/bin/tot.mjs +148 -57
  3. package/package.json +6 -1
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +4 -4
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +3 -3
  8. package/src/commands/accept.mjs +498 -59
  9. package/src/commands/app/dev.mjs +8 -4
  10. package/src/commands/app/index.mjs +3 -3
  11. package/src/commands/app/scaffold.mjs +1 -1
  12. package/src/commands/branches.mjs +297 -0
  13. package/src/commands/cleanup.mjs +264 -0
  14. package/src/commands/clone.mjs +307 -25
  15. package/src/commands/dev.mjs +440 -156
  16. package/src/commands/doctor.mjs +4 -4
  17. package/src/commands/git-credential.mjs +180 -0
  18. package/src/commands/go-live.mjs +9 -5
  19. package/src/commands/grants.mjs +7 -5
  20. package/src/commands/hotfix.mjs +428 -0
  21. package/src/commands/ideas.mjs +2 -2
  22. package/src/commands/link.mjs +2 -2
  23. package/src/commands/login.mjs +5 -6
  24. package/src/commands/pr.mjs +62 -25
  25. package/src/commands/preview-build.mjs +6 -6
  26. package/src/commands/preview-doctor.mjs +225 -0
  27. package/src/commands/preview-retry-evidence.mjs +156 -0
  28. package/src/commands/preview.mjs +19 -3
  29. package/src/commands/revert.mjs +322 -0
  30. package/src/commands/rollback.mjs +18 -16
  31. package/src/commands/ship.mjs +51 -14
  32. package/src/commands/start.mjs +101 -59
  33. package/src/commands/submit.mjs +1183 -169
  34. package/src/commands/sync.mjs +203 -0
  35. package/src/commands/validate.mjs +10 -4
  36. package/src/commands/whoami.mjs +1 -1
  37. package/src/dev-heartbeat.mjs +3 -2
  38. package/src/dev-logs.mjs +2 -2
  39. package/src/errors.mjs +11 -4
  40. package/src/git-credential.mjs +257 -0
  41. package/src/last-tenant.mjs +1 -1
  42. package/src/mcp.mjs +6 -1
  43. package/src/merge-doctor-report.mjs +208 -0
  44. package/src/no-gitea-links.test.mjs +55 -0
  45. package/src/oauth.mjs +18 -14
  46. package/src/obstacle-beacon.cjs +2 -2
  47. package/src/obstacle.mjs +1 -1
  48. package/src/plan.mjs +83 -15
  49. package/src/sample.mjs +4 -4
  50. package/src/validate.mjs +187 -15
  51. package/src/vendor/private-apps-devkit.mjs +3 -3
  52. package/src/viewer-session.mjs +118 -0
  53. package/template/private-app/README.md +12 -6
  54. package/src/commands/retire.mjs +0 -203
@@ -7,7 +7,7 @@
7
7
  * tot pr close <N|id> close (reject) a candidate without merging
8
8
  *
9
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
10
+ * re-submit UPDATES your open candidate; `tot submit --fork-candidate` forks another. This
11
11
  * command fills the gap the raw submit loop left — a first-party way to list your
12
12
  * open candidates and to close one (candidate close was otherwise gated to
13
13
  * version-control apps only).
@@ -17,9 +17,9 @@
17
17
  * `candidate_status` / `candidate_close` tools. Dependency-free (global fetch +
18
18
  * `git`).
19
19
  *
20
- * OPERATOR MODE (unit U17). `tot pr list --tenant <t>` lists a tenant's OPEN
20
+ * OPERATOR MODE. `tot pr list --tenant <t>` lists a tenant's OPEN
21
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`
22
+ * --tenant <t>`. It reuses that command's exact transport: the SAME `GET /api/changes`
23
23
  * HTTP endpoint + operator-secret Bearer auth (`resolveOperatorSecret`,
24
24
  * `normalizeChangesQueue` from ship.mjs). `view`/`close` stay developer-only.
25
25
  */
@@ -28,9 +28,8 @@ import { createMcpClient } from "../mcp.mjs";
28
28
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
29
29
  import { fail } from "../errors.mjs";
30
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.
31
+ // `tot pr list` reuses `tot ship --pr`'s operator-secret transport helpers verbatim
32
+ // (same env precedence, same queue normalisation) so the two speak one wire, not two.
34
33
  import { resolveOperatorSecret, normalizeChangesQueue } from "./ship.mjs";
35
34
  import {
36
35
  defaultCandidateStatePath,
@@ -42,6 +41,24 @@ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
42
41
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
43
42
  const SUBCOMMANDS = ["list", "view", "close"];
44
43
 
44
+ /**
45
+ * The storefront-owned, shareable `/preview/<tenant>/pr/<N>` link — NEVER the
46
+ * forge/Gitea `url` (2026-08-18 incident: a raw forge PR URL reached an
47
+ * owner). `candidate_status` (the local-checkout MCP tool) has no
48
+ * `previewUrl` field at all, unlike the operator `GET /api/changes` path — so
49
+ * this constructs it the same way `runPrListOperator`'s caller resolves
50
+ * `storefrontUrl`, from the same env/--url override chain. Pure.
51
+ * @param {string} storefrontUrl
52
+ * @param {string} tenant
53
+ * @param {number|null|undefined} prNumber
54
+ * @returns {string|null}
55
+ */
56
+ export function buildPreviewUrl(storefrontUrl, tenant, prNumber) {
57
+ if (typeof prNumber !== "number" || !tenant) return null;
58
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
59
+ return `${base}/preview/${tenant}/pr/${prNumber}`;
60
+ }
61
+
45
62
  const USAGE = `tot pr — see and manage candidate PRs
46
63
 
47
64
  tot pr [list] list your open candidate PRs for this store
@@ -53,7 +70,7 @@ const USAGE = `tot pr — see and manage candidate PRs
53
70
 
54
71
  DEVELOPER (default) — run inside your OWN store checkout: lists/manages the
55
72
  candidates \`tot submit\` opens. A re-submit updates your open one by default;
56
- \`tot submit --new\` forks another.
73
+ \`tot submit --fork-candidate\` forks another.
57
74
 
58
75
  OPERATOR — \`tot pr list --tenant <appDomain>\` lists ANY tenant's open candidate
59
76
  queue WITHOUT a checkout (the read-only companion to \`tot ship --pr <N> --tenant\`).
@@ -117,10 +134,12 @@ export function matchCandidate(candidates, target) {
117
134
 
118
135
  /**
119
136
  * One-line candidate summary for `tot pr list` — surfaces branch ↔ PR# ↔ preview
120
- * URL so a dev sees, at a glance, which git branch each candidate belongs to (u4 —
121
- * branch-bound candidates) and where its preview lives. Prefers the candidate's
122
- * `previewUrl`, falling back to the PR `url`. `active` marks the one THIS checkout's
123
- * branch resolves to. Pure unit-tested.
137
+ * URL so a dev sees, at a glance, which git branch each candidate belongs to
138
+ * (branch-bound candidates) and where its preview lives. ONLY `previewUrl` (the
139
+ * storefront-owned `/preview/<tenant>/pr/<N>` link) NEVER `url` (the forge/
140
+ * Gitea `html_url`), which must never reach a terminal (2026-08-18 incident:
141
+ * a raw forge PR URL reached an owner). `active` marks the one
142
+ * THIS checkout's branch resolves to. Pure — unit-tested.
124
143
  * @param {{prNumber?:number|null, branch?:string|null, changeId:string, state?:string|null,
125
144
  * previewUrl?:string|null, url?:string|null}} c
126
145
  * @param {{ active?: boolean }} [opts]
@@ -128,16 +147,15 @@ export function matchCandidate(candidates, target) {
128
147
  export function formatCandidateLine(c, { active = false } = {}) {
129
148
  const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
130
149
  const branch = c.branch ? c.branch : "(no branch)";
131
- const previewUrl = c.previewUrl || c.url || null;
132
- const urlPart = previewUrl ? ` ${previewUrl}` : "";
150
+ const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
133
151
  const activePart = active ? " ← active" : "";
134
152
  return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
135
153
  }
136
154
 
137
- // ─── Operator queue listing (unit U17) ──────────────────────────────────────────────
155
+ // ─── Operator queue listing ──────────────────────────────────────────────────────────
138
156
  //
139
- // The read-only companion to `tot ship --pr <N> --tenant <t>` (U16): list a tenant's
140
- // OPEN candidate queue WITHOUT a checkout. It reuses U16's exact transport — the SAME
157
+ // The read-only companion to `tot ship --pr <N> --tenant <t>`: list a tenant's
158
+ // OPEN candidate queue WITHOUT a checkout. It reuses that command's exact transport — the SAME
141
159
  // `GET /api/changes` endpoint (operator accept queue) + Bearer operator-secret auth
142
160
  // (`resolveOperatorSecret` / `normalizeChangesQueue` from ship.mjs) — so a listing and
143
161
  // a ship read one wire. NO merge/deploy/side-effect; pure listing.
@@ -153,8 +171,15 @@ export function formatCandidateLine(c, { active = false } = {}) {
153
171
  * sees it's listable but must `tot preview build` before it can ship.
154
172
  * Adapts `formatCandidateLine`'s house style to the `GET /api/changes` shape (`status`
155
173
  * not `state`; `previewUrl`; a `headSha` to short-render). Pure — unit-tested.
174
+ *
175
+ * A built row whose `evidence.pushFailed` is set (the MCP `change_report_evidence`
176
+ * push never landed — fb-1787938036559-ppmu4e) gets a `[evidence push failed]` tag
177
+ * + a `tot preview retry-evidence` hint, so the gap between "storefront thinks this
178
+ * is promotable" and "MCP has no evidence for it" is visible right where an
179
+ * operator is already looking, not just in the activity log.
156
180
  * @param {{prNumber?:number|null, status?:string|null, changeId?:string|null,
157
- * headSha?:string|null, previewUrl?:string|null, built?:boolean}} c
181
+ * headSha?:string|null, previewUrl?:string|null, built?:boolean,
182
+ * evidence?:{pushFailed?:boolean}|null}} c
158
183
  * @returns {string}
159
184
  */
160
185
  export function formatOperatorCandidateLine(c) {
@@ -171,6 +196,13 @@ export function formatOperatorCandidateLine(c) {
171
196
  }
172
197
  const status = c.status ?? "?";
173
198
  const urlPart = c.previewUrl ? ` ${c.previewUrl}` : "";
199
+ if (c.evidence?.pushFailed) {
200
+ const retryHint =
201
+ typeof c.prNumber === "number"
202
+ ? ` → tot preview retry-evidence --pr ${c.prNumber}`
203
+ : " → tot preview retry-evidence";
204
+ return ` PR ${pr} [${status}] [evidence push failed] ${c.changeId} ${head}${urlPart}${retryHint}`;
205
+ }
174
206
  return ` PR ${pr} [${status}] ${c.changeId} ${head}${urlPart}`;
175
207
  }
176
208
 
@@ -191,7 +223,7 @@ export function sortQueueByPrDesc(changes) {
191
223
  /**
192
224
  * The OPERATOR `tot pr list --tenant <t>` flow: fetch the tenant's OPEN candidate
193
225
  * queue over `GET /api/changes` (Bearer operator secret + `X-Tot-Owner` +
194
- * `x-tot-capability: ship-on-behalf`, mirroring U16) and print each candidate one per
226
+ * `x-tot-capability: ship-on-behalf`) and print each candidate one per
195
227
  * line, sorted by PR number desc. Fail-closed: no secret → honest refusal (exit 2)
196
228
  * BEFORE any network. `fetch` is injected so it's unit-tested with no live network.
197
229
  *
@@ -206,8 +238,9 @@ export async function runPrListOperator({ tenant, secret, storefrontUrl = null }
206
238
  if (!secret) {
207
239
  console.error(
208
240
  fail(
209
- "listing a tenant's queue is an OPERATOR action — it needs an operator secret",
210
- "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
241
+ "no operator secret — view this tenant's queue from the storefront admin instead.",
242
+ `open https://${tenant}/admin → Publish tab to see the candidate queue and act on it. ` +
243
+ "Operators/CI: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret.",
211
244
  ),
212
245
  );
213
246
  return 2;
@@ -268,12 +301,12 @@ export async function run(argv, ctx) {
268
301
  console.log(USAGE);
269
302
  return 0;
270
303
  }
271
- if (!SUBCOMMANDS.includes(args.sub)) {
304
+ if (!SUBCOMMANDS.includes(/** @type {any} */ (args.sub))) {
272
305
  console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
273
306
  return 2;
274
307
  }
275
308
 
276
- // OPERATOR MODE (U17): `--tenant <t>` lists that tenant's OPEN candidate queue with
309
+ // OPERATOR MODE: `--tenant <t>` lists that tenant's OPEN candidate queue with
277
310
  // NO checkout, over the same HTTP transport `tot ship --pr` uses. Only `list` has an
278
311
  // operator path today — `view`/`close` stay developer-only (checkout-bound).
279
312
  if (args.tenant && `${args.tenant}`.trim()) {
@@ -326,8 +359,9 @@ export async function run(argv, ctx) {
326
359
  }
327
360
 
328
361
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
362
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
329
363
  const statePath = defaultCandidateStatePath(env);
330
- // Branch-bound (u4): the active-pointer namespace is scoped to the current git
364
+ // Branch-bound: the active-pointer namespace is scoped to the current git
331
365
  // branch, so the "← active" marker reflects THIS branch's candidate.
332
366
  const scope = { mcpUrl: baseUrl, repo, branch: currentBranch(gitSafe) };
333
367
  const client = createMcpClient(baseUrl);
@@ -347,7 +381,8 @@ export async function run(argv, ctx) {
347
381
  const active = readActiveChangeId(statePath, scope);
348
382
  console.log(`Open candidate PRs for ${repo}:`);
349
383
  for (const c of candidates) {
350
- console.log(formatCandidateLine(c, { active: !!active && c.changeId === active }));
384
+ const previewUrl = c.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, c.prNumber);
385
+ console.log(formatCandidateLine({ ...c, previewUrl }, { active: !!active && c.changeId === active }));
351
386
  }
352
387
  return 0;
353
388
  }
@@ -366,7 +401,9 @@ export async function run(argv, ctx) {
366
401
  if (match.headSha) console.log(` head: ${match.headSha}`);
367
402
  if (match.baseSha) console.log(` base: ${match.baseSha}`);
368
403
  console.log(` mergeable (forge): ${match.mergeable ?? "?"}`);
369
- if (match.url) console.log(` ${match.url}`);
404
+ // ONLY the storefront-owned preview link -- never the raw forge/Gitea `url`.
405
+ const previewUrl = match.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, match.prNumber);
406
+ if (previewUrl) console.log(` ${previewUrl}`);
370
407
  return 0;
371
408
  }
372
409
 
@@ -1,10 +1,10 @@
1
1
  /**
2
- * `tot preview build --tenant <t> --pr <N>` — OPERATOR build-on-demand (unit U3).
2
+ * `tot preview build --tenant <t> --pr <N>` — OPERATOR build-on-demand.
3
3
  *
4
4
  * Materialize ANY PR's hosted preview (including one that isn't yours, and one that
5
5
  * was orphaned — its webhook never processed so it has no ReviewEnvironment). It
6
- * calls the session-authenticated storefront endpoint `POST /api/preview/build`
7
- * (unit U1), which runs the EXISTING `reconcileCandidate` against the PR head. The
6
+ * calls the session-authenticated storefront endpoint `POST /api/preview/build`,
7
+ * which runs the EXISTING `reconcileCandidate` against the PR head. The
8
8
  * CLI passes the KNOWN descriptor (the head sha it holds), so there is NO forge
9
9
  * PR-read dependency: reconcile reads the tenant files at that sha (content-addressed).
10
10
  *
@@ -22,9 +22,9 @@
22
22
  * URL) and confirms before acting (`--yes` to skip; a non-TTY without `--yes`
23
23
  * aborts rather than acting silently). Build-on-demand is INERT — it flips no shared
24
24
  * channel and is not go-live — so this is a materialize, not a deploy. The plan
25
- * itself is built by the SHARED plan module (`../plan.mjs`, unit U10) — the same
26
- * affordance every other mutating operator verb (accept/ship/retire) and the
27
- * `/admin` confirm dialog use, per decision `operator-verb-and-hosting-model`.
25
+ * itself is built by the SHARED plan module (`../plan.mjs`) — the same
26
+ * affordance every other mutating operator verb (accept/ship/reject) and the
27
+ * `/admin` confirm dialog use.
28
28
  *
29
29
  * Dependency-free (global fetch + `git` for the optional head-sha default).
30
30
  */
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `tot preview doctor --tenant <t>` — the merge self-serve DIAGNOSIS in the terminal.
3
+ *
4
+ * Calls the hosted merge-doctor seam `GET /<tenant>/api/preview/merge-doctor`
5
+ * — which composes the SAME two server-side reads the admin Publish tab builds
6
+ * (`GET /api/changes` + `GET /api/changes/branches`) through the PURE `analyzeMerge`
7
+ * taxonomy — and prints its `{ scopeKnown, verdict, findings, counts }` as the compact,
8
+ * ordered (blocker → warn → info) report, each finding carrying who-owns-it and its
9
+ * remedy/action. It composes NO per-PR forge calls of its own: one GET, one answer.
10
+ *
11
+ * READ-ONLY — it mutates nothing, so there is NO plan/confirm gate (unlike `tot
12
+ * accept` / `tot preview build`). It just answers "what's blocking my merges, and
13
+ * what do I do about it?" in seconds.
14
+ *
15
+ * AUTH — the endpoint is session-gated exactly like `GET /api/changes`, so this
16
+ * reaches it by EITHER of the two transports `tot accept` already speaks:
17
+ * - OPERATOR SECRET (operators / headless CI): Bearer + `X-Tot-Owner` on the
18
+ * generic storefront origin (from PREVIEW_RECONCILE_SECRET / GRANTS_ADMIN_SECRET
19
+ * / TOT_OPERATOR_SECRET, or `--secret`).
20
+ * - VIEWER SESSION (an invited developer with no secret): mint a `tot_session`
21
+ * from their own `tot login` on the tenant's own host (`resolveViewerTransport`).
22
+ * A caller who cannot see the accept queue cannot get a diagnosis of it — an auth
23
+ * refusal (401/403) is relayed as a clean house-style failure, not a wrapped verdict.
24
+ *
25
+ * EXIT CODE — mirrors the `scripts/tenant/gitea-merge-doctor.mjs` driver: a report
26
+ * with any blocker exits 1 (so a CI gate fails on a blocked merge), otherwise 0.
27
+ *
28
+ * The rendering helpers are the SHARED port in `../merge-doctor-report.mjs` (a
29
+ * faithful PORT of the analyzer's own `formatReport`/`attentionBanner`/
30
+ * `supportContext` — mergeDoctor.ts §render / the gitea-merge-doctor.mjs mirror);
31
+ * this package is the dependency-free published `@tokenoftrust/cli`, so it cannot
32
+ * import the app/scripts source, and the port is kept in sync with that taxonomy by
33
+ * hand. They live in their own module so `tot accept` / `tot ship` can reuse the same
34
+ * fetch + compact render without a cycle back through this command's transports.
35
+ *
36
+ * Dependency-free (global fetch + the shared viewer/operator transports).
37
+ */
38
+ import { fail } from "../errors.mjs";
39
+ import { resolveOperatorSecret } from "./ship.mjs";
40
+ import { resolveViewerTransport } from "../viewer-session.mjs";
41
+ import {
42
+ DOCTOR_PATH,
43
+ normalizeAnalysis,
44
+ attentionBanner,
45
+ supportContext,
46
+ formatReport,
47
+ } from "../merge-doctor-report.mjs";
48
+
49
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
50
+
51
+ const USAGE = `tot preview doctor — diagnose what's blocking this tenant's merges
52
+
53
+ tot preview doctor diagnose the current checkout's tenant
54
+ tot preview doctor --tenant <t> diagnose <t> (works without a checkout)
55
+ tot preview doctor --json emit the raw { scopeKnown, verdict, findings, counts }
56
+
57
+ Calls the hosted merge-doctor (GET /api/preview/merge-doctor), which composes the
58
+ SAME reads the admin Publish tab builds and runs them through the shared analyzer,
59
+ and prints the ordered report: blockers first, then warnings, then info — each
60
+ finding names who resolves it (you / on-us / housekeeping) and the exact remedy.
61
+ Read-only: it mutates nothing and needs no confirmation.
62
+
63
+ Exit code: 1 if the report has any blocker (so it can gate CI), else 0.
64
+
65
+ Options:
66
+ --tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
67
+ current checkout's tenant when run inside one.
68
+ --url <origin> Storefront origin for the operator-secret path
69
+ (default: env TOT_STOREFRONT_URL or ${DEFAULT_STOREFRONT_URL}).
70
+ --secret <s> Operator secret (prefer the env vars below).
71
+ --json Print the raw analysis JSON instead of the report.
72
+ --help, -h Show this help.
73
+
74
+ Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
75
+ GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret). Without one, an
76
+ invited developer's own \`tot login\` session is used.`;
77
+
78
+ /** Parse `tot preview doctor` argv. Pure — unit-testable. */
79
+ export function parsePreviewDoctorArgs(argv) {
80
+ const a = { tenant: null, url: null, secret: null, json: false, help: false };
81
+ for (let i = 0; i < argv.length; i++) {
82
+ const t = argv[i];
83
+ if (t === "--tenant") a.tenant = argv[++i];
84
+ else if (t === "--url") a.url = argv[++i];
85
+ else if (t === "--secret") a.secret = argv[++i];
86
+ else if (t === "--json") a.json = true;
87
+ else if (t === "--help" || t === "-h") a.help = true;
88
+ }
89
+ return a;
90
+ }
91
+
92
+ // The render/normalize helpers (normalizeAnalysis / attentionBanner /
93
+ // supportContext / formatReport) are the shared port in ../merge-doctor-report.mjs,
94
+ // imported at the top of this file.
95
+
96
+ // ── run ────────────────────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Fetch + render the merge-doctor report for one tenant. Resolves the SAME two
100
+ * transports `tot accept` uses (operator secret → generic origin + X-Tot-Owner;
101
+ * else viewer session → tenant host + cookie), GETs the hosted seam, and prints the
102
+ * ordered report (or raw JSON). `fetch`/`resolveViewerTransport` are injected so it
103
+ * is unit-tested with no network. Returns the process exit code (blocker → 1).
104
+ *
105
+ * @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
106
+ * json?:boolean, env?:NodeJS.ProcessEnv }} params
107
+ * @param {{ fetch?:typeof fetch, resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
108
+ * @returns {Promise<number>}
109
+ */
110
+ export async function runDoctor(
111
+ { tenant, secret, storefrontUrl = null, json = false, env = process.env },
112
+ deps = {},
113
+ ) {
114
+ const fetchImpl = deps.fetch || globalThis.fetch;
115
+ const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
116
+
117
+ // Resolve the transport — operator secret preferred, else the developer's own
118
+ // viewer session on the tenant host.
119
+ let base;
120
+ let authHeaders;
121
+ if (secret) {
122
+ base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
123
+ authHeaders = {
124
+ authorization: `Bearer ${secret}`,
125
+ "x-tot-owner": tenant,
126
+ "x-tot-capability": "ship-on-behalf",
127
+ };
128
+ } else {
129
+ const viewer = await resolveViewer({ tenant, env, fetchImpl });
130
+ if (!viewer.ok) {
131
+ console.error(fail(viewer.message, viewer.hint));
132
+ return 2;
133
+ }
134
+ base = viewer.base;
135
+ authHeaders = viewer.authHeaders;
136
+ }
137
+
138
+ let res;
139
+ try {
140
+ res = await fetchImpl(`${base}${DOCTOR_PATH}`, { method: "GET", headers: authHeaders });
141
+ } catch (e) {
142
+ console.error(
143
+ fail(
144
+ `couldn't reach the merge-doctor at ${base}: ${String(e?.message || e)}`,
145
+ "check --url / your network, then re-run",
146
+ ),
147
+ );
148
+ return 1;
149
+ }
150
+
151
+ let data = {};
152
+ try {
153
+ data = await res.json();
154
+ } catch {
155
+ /* non-JSON / empty body */
156
+ }
157
+
158
+ // Session gating: the endpoint relays the queue read's auth refusal verbatim, so a
159
+ // caller who cannot see the accept queue cannot diagnose it.
160
+ if (res.status === 401 || res.status === 403) {
161
+ console.error(
162
+ fail(
163
+ `not authorized to diagnose ${tenant}'s merges: ${data?.error || `HTTP ${res.status}`}`,
164
+ "you need a live ship-on-behalf grant on this tenant (ask the store owner), or an operator secret authorised for it — then re-run",
165
+ ),
166
+ );
167
+ return 2;
168
+ }
169
+
170
+ if (!res.ok) {
171
+ console.error(
172
+ fail(
173
+ `the merge-doctor did not answer for ${tenant}: ${data?.error || `HTTP ${res.status}`}`,
174
+ "check --tenant / --url, then re-run",
175
+ ),
176
+ );
177
+ return 1;
178
+ }
179
+
180
+ const analysis = normalizeAnalysis(data);
181
+
182
+ if (json) {
183
+ console.log(JSON.stringify(analysis, null, 2));
184
+ } else {
185
+ console.log(formatReport(analysis));
186
+ }
187
+
188
+ // Mirror the scripts driver: any blocker fails the run so a CI gate catches it.
189
+ return analysis.counts.blocker > 0 ? 1 : 0;
190
+ }
191
+
192
+ /**
193
+ * @param {string[]} argv
194
+ * @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
195
+ */
196
+ export async function run(argv, ctx) {
197
+ const env = process.env;
198
+ const args = parsePreviewDoctorArgs(argv);
199
+ if (args.help) {
200
+ console.log(USAGE);
201
+ return 0;
202
+ }
203
+
204
+ const tenant = (args.tenant || ctx?.tenant || "").trim();
205
+ if (!tenant) {
206
+ console.error(
207
+ fail(
208
+ "no target tenant.",
209
+ "pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
210
+ ),
211
+ );
212
+ return 2;
213
+ }
214
+
215
+ const storefrontUrl =
216
+ args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
217
+
218
+ return await runDoctor({
219
+ tenant,
220
+ secret: resolveOperatorSecret(args.secret, env),
221
+ storefrontUrl,
222
+ json: args.json,
223
+ env,
224
+ });
225
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `tot preview retry-evidence --tenant <t> --pr <N>` — OPERATOR recovery verb.
3
+ *
4
+ * Re-push evidence for an ALREADY-BUILT candidate whose `change_report_evidence`
5
+ * push to MCP failed (surfaced as `evidence.pushFailed` on the candidate — see
6
+ * `formatOperatorCandidateLine`'s `[evidence push failed]` tag in `tot pr list
7
+ * --tenant`). Calls the session-authenticated `POST
8
+ * /api/changes/candidate-evidence-retry`, which recomputes + re-pushes evidence
9
+ * for the candidate's EXISTING version — no rebuild, no new content, safe to
10
+ * run repeatedly.
11
+ *
12
+ * DISTINCT FROM `tot preview build`: build re-runs the full reconcile against a
13
+ * head sha (for an orphaned/never-built PR). This targets an ALREADY-ready
14
+ * candidate whose content is fine but whose evidence never reached MCP — a
15
+ * narrower, cheaper fix for fb-1787938036559-ppmu4e.
16
+ *
17
+ * AUTH — same headless Bearer-operator-secret path as `tot preview build`:
18
+ * `Authorization: Bearer <secret>`, `X-Tot-Owner: <tenant>`,
19
+ * `X-Tot-Capability: ship-on-behalf`.
20
+ *
21
+ * Non-destructive + idempotent — no confirmation prompt.
22
+ *
23
+ * Dependency-free (global fetch).
24
+ */
25
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
26
+
27
+ /** Parse `tot preview retry-evidence` argv. Pure — unit-testable. */
28
+ export function parseRetryEvidenceArgs(argv) {
29
+ const a = { tenant: null, pr: null, changeId: null, url: null, secret: null, help: false };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const t = argv[i];
32
+ if (t === "--tenant") a.tenant = argv[++i];
33
+ else if (t === "--pr") a.pr = argv[++i];
34
+ else if (t === "--change-id") a.changeId = argv[++i];
35
+ else if (t === "--url") a.url = argv[++i];
36
+ else if (t === "--secret") a.secret = argv[++i];
37
+ else if (t === "--help" || t === "-h") a.help = true;
38
+ }
39
+ return a;
40
+ }
41
+
42
+ export function renderUsage() {
43
+ return `tot preview retry-evidence — operator recovery: re-push a candidate's evidence to MCP
44
+
45
+ Usage:
46
+ tot preview retry-evidence --tenant <appDomain> --pr <N> [options]
47
+ tot preview retry-evidence --tenant <appDomain> --change-id <id> [options]
48
+
49
+ Re-computes and re-pushes evidence for an ALREADY-BUILT candidate whose MCP
50
+ push failed (shown as "[evidence push failed]" in \`tot pr list --tenant\`).
51
+ Does NOT rebuild or change content — safe to run repeatedly.
52
+
53
+ Options:
54
+ --tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
55
+ current checkout's tenant when run inside one.
56
+ --pr <N> PR number to retry evidence for.
57
+ --change-id <id> Optional explicit candidate id (defaults to pr-<N>).
58
+ --url <origin> Storefront origin. Defaults to $TOT_STOREFRONT_URL or
59
+ ${DEFAULT_STOREFRONT_URL}.
60
+ --secret <s> Operator secret. Prefer the env vars below.
61
+ --help, -h Show this help.
62
+
63
+ Auth (operator secret, from env, first found):
64
+ PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET`;
65
+ }
66
+
67
+ /**
68
+ * @param {string[]} argv
69
+ * @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
70
+ */
71
+ export async function run(argv, ctx) {
72
+ const args = parseRetryEvidenceArgs(argv);
73
+ if (args.help) {
74
+ console.log(renderUsage());
75
+ return 0;
76
+ }
77
+
78
+ const base = (args.url || process.env.TOT_STOREFRONT_URL || process.env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL)
79
+ .trim()
80
+ .replace(/\/+$/, "");
81
+
82
+ const tenant = (args.tenant || ctx?.tenant || "").trim();
83
+ if (!tenant) {
84
+ console.error(
85
+ "✗ no target tenant.\n\n → next: pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), " +
86
+ "or run inside a store checkout.",
87
+ );
88
+ return 2;
89
+ }
90
+
91
+ const prRaw = args.pr;
92
+ const pr = prRaw != null && `${prRaw}`.trim() && Number.isFinite(Number(prRaw)) ? Number(prRaw) : null;
93
+ const changeId = (args.changeId || "").trim() || null;
94
+ if (pr == null && !changeId) {
95
+ console.error(
96
+ "✗ no candidate to retry evidence for.\n\n → next: pass --pr <N> (the PR number), or --change-id <id>.",
97
+ );
98
+ return 2;
99
+ }
100
+
101
+ const secret = (args.secret || process.env.PREVIEW_RECONCILE_SECRET || process.env.GRANTS_ADMIN_SECRET || process.env.TOT_OPERATOR_SECRET || "").trim();
102
+ if (!secret) {
103
+ console.error(
104
+ "✗ no operator secret.\n\n → next: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / " +
105
+ "TOT_OPERATOR_SECRET) in the environment.",
106
+ );
107
+ return 2;
108
+ }
109
+
110
+ const body = {
111
+ ...(pr != null ? { pr } : {}),
112
+ ...(changeId ? { changeId } : {}),
113
+ };
114
+
115
+ let res;
116
+ try {
117
+ res = await fetch(`${base}/api/changes/candidate-evidence-retry`, {
118
+ method: "POST",
119
+ headers: {
120
+ "content-type": "application/json",
121
+ authorization: `Bearer ${secret}`,
122
+ "x-tot-owner": tenant,
123
+ "x-tot-capability": "ship-on-behalf",
124
+ },
125
+ body: JSON.stringify(body),
126
+ });
127
+ } catch (e) {
128
+ console.error(`✗ could not reach ${base}: ${e?.message || e}\n\n → next: check --url / your network.`);
129
+ return 1;
130
+ }
131
+
132
+ let data = {};
133
+ try {
134
+ data = await res.json();
135
+ } catch {
136
+ /* non-JSON error body */
137
+ }
138
+
139
+ const label = pr != null ? `PR #${pr}` : changeId;
140
+
141
+ if (res.ok && data?.ok && data?.pushed) {
142
+ console.log(`✓ Evidence re-pushed for ${label} on ${tenant} (promotable: ${data.promotable}).`);
143
+ return 0;
144
+ }
145
+ if (res.ok && data?.ok && !data?.pushed) {
146
+ console.error(`✗ Evidence push still failed for ${label} on ${tenant}.`);
147
+ if (data.error) console.error(` • ${data.error}`);
148
+ console.error("\n → next: check MCP reachability, then re-run this command.");
149
+ return 1;
150
+ }
151
+
152
+ const message = data?.error || `HTTP ${res.status}`;
153
+ console.error(`✗ Retry did not succeed (HTTP ${res.status}).`);
154
+ console.error(` • ${message}`);
155
+ return 1;
156
+ }
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * tot dev run your store locally with save→reload
7
7
  * tot preview push it to a reviewable preview (validate → reconcile → compliance) ← you are here
8
- * tot ship promote a reconciled preview live ← unit u3
8
+ * tot ship promote a reconciled preview live
9
9
  *
10
10
  * The whole preview flow (validate, push the preview ref, open/update the PR-backed
11
11
  * candidate, stream the reconcile/compliance/preview result) lives in submit.mjs — this
@@ -45,12 +45,12 @@ export function postRunHint(code, alias) {
45
45
  /**
46
46
  * @param {string[]} argv
47
47
  * @param {any} ctx
48
- * @param {{ alias?: string|null }} [opts] `alias` is the old verb the developer typed
48
+ * @param {{ alias?: string|null }} [opts] - `alias` is the old verb the developer typed
49
49
  * (`"submit"` / `"deploy"`) when this flow is reached as a teaching alias; null/omitted
50
50
  * for the first-class `tot preview`.
51
51
  */
52
52
  export async function run(argv, ctx, { alias = null } = {}) {
53
- // `tot preview build …` is the OPERATOR build-on-demand subcommand (unit U3)
53
+ // `tot preview build …` is the OPERATOR build-on-demand subcommand —
54
54
  // a distinct verb from the developer preview flow, so it's dispatched BEFORE the
55
55
  // submit-flow arg parse. Only the first-class `tot preview` carries it (not the
56
56
  // `submit`/`deploy` teaching aliases, which are the push-your-checkout flow).
@@ -59,6 +59,22 @@ export async function run(argv, ctx, { alias = null } = {}) {
59
59
  return runBuild(argv.slice(1), ctx);
60
60
  }
61
61
 
62
+ // `tot preview doctor …` is the read-only merge DIAGNOSIS subcommand —
63
+ // a distinct verb from the developer preview flow, dispatched before the submit-
64
+ // flow arg parse (like `build`). Only the first-class `tot preview` carries it.
65
+ if (!alias && argv[0] === "doctor") {
66
+ const { run: runDoctor } = await import("./preview-doctor.mjs");
67
+ return runDoctor(argv.slice(1), ctx);
68
+ }
69
+
70
+ // `tot preview retry-evidence …` is the OPERATOR recovery subcommand for a
71
+ // candidate whose evidence push to MCP failed (fb-1787938036559-ppmu4e) —
72
+ // dispatched before the submit-flow arg parse, like `build`/`doctor`.
73
+ if (!alias && argv[0] === "retry-evidence") {
74
+ const { run: runRetryEvidence } = await import("./preview-retry-evidence.mjs");
75
+ return runRetryEvidence(argv.slice(1), ctx);
76
+ }
77
+
62
78
  const verb = alias || "preview";
63
79
  const args = parseArgs(argv);
64
80