@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
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * `tot accept --tenant <t> --pr <N>` (alias: `tot merge`) — OPERATOR verb: QUEUE a
3
3
  * PR's integration into the protected `preview` AGGREGATE. This SUPERSEDES the
4
- * retired `tot accept` = merge-PR→main semantics (operator-console U5): accepting a
4
+ * retired `tot accept` = merge-PR→main semantics: accepting a
5
5
  * change no longer merges it to main — it enqueues it into the tenant's shared
6
- * `preview` aggregate, where b07's tenant-serialized queue merges it (b06
6
+ * `preview` aggregate, where the tenant-serialized queue merges it (via
7
7
  * `candidate_accept`, preview-base only), rebuilds the aggregate, and moves the
8
8
  * shared preview pointer ONLY when combined evidence is green.
9
9
  *
@@ -14,20 +14,20 @@
14
14
  * see.
15
15
  *
16
16
  * TARGET RESOLUTION — `--pr N --tenant t` names the PR; the server resolves it to its
17
- * candidate from the tenant's ReviewEnvironment index (unit b04 — tenant + PR, NO
17
+ * candidate from the tenant's ReviewEnvironment index (tenant + PR, NO
18
18
  * chg-id). An explicit `--change-id` is still accepted (targets a specific record),
19
- * but is no longer REQUIRED — the whole point of b08 is that a PR number is enough.
19
+ * but is no longer REQUIRED — a PR number alone is enough.
20
20
  *
21
21
  * TRANSPORT — the honest accept path is `POST /api/changes/integrate` (the ONE call
22
- * site of b07's `TenantIntegrationQueue.enqueue`). Like `tot ship --pr` (U16) and
23
- * `tot pr list --tenant` (U17), the CLI reaches it with the OPERATOR-SECRET Bearer
22
+ * site of `TenantIntegrationQueue.enqueue`). Like `tot ship --pr` and
23
+ * `tot pr list --tenant`, the CLI reaches it with the OPERATOR-SECRET Bearer
24
24
  * transport (`resolveOperatorSecret` + `X-Tot-Owner` + `x-tot-capability`), since the
25
25
  * CLI holds no storefront cookie. The response is the honest `IntegrateOutcome` —
26
26
  * `queueState` / `runState` / `pointerMoved` / `aggregateSha` / `statusMessage` —
27
27
  * which this verb renders VERBATIM, never a bare "merged".
28
28
  *
29
29
  * HUMAN GATE — integrating into the shared preview is a decision a human makes, so
30
- * this ALWAYS states the EXACT plan (shared `planForAction`, unit U10: which PR,
30
+ * this ALWAYS states the EXACT plan (shared `planForAction`: which PR,
31
31
  * which tenant, "queue for integration into the preview aggregate — NO merge, NO
32
32
  * go-live") and requires an explicit confirm. `--yes` is an explicit affirmative; a
33
33
  * non-TTY without `--yes` is refused (mirrors `tot ship`'s non-TTY refusal).
@@ -39,8 +39,31 @@ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
39
39
  // Reuse `tot ship`'s operator-secret precedence verbatim so accept + ship + pr-list
40
40
  // speak ONE operator-auth contract, not three.
41
41
  import { resolveOperatorSecret } from "./ship.mjs";
42
+ // Automate-first conflict recovery: the ONE-CLICK `--refresh` path calls the
43
+ // `candidate_refresh` MCP tool —
44
+ // which has NO operator-secret HTTP route, so it is reached over the MCP client the
45
+ // same way `tot pr` reaches `candidate_status`/`candidate_close`.
46
+ import { createMcpClient } from "../mcp.mjs";
47
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
48
+ // No-operator-secret path: mint a viewer session from the developer's OWN `tot`
49
+ // login and integrate as themselves (server gates on their live ship-on-behalf grant).
50
+ import { resolveViewerTransport } from "../viewer-session.mjs";
51
+ // The doctor PUSH: on a failed accept/integrate, auto-append the COMPACT
52
+ // "here's what's blocking you" summary over the SAME transport we just used, so a
53
+ // developer doesn't have to remember to run `tot preview doctor` themselves.
54
+ import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
42
55
 
43
56
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
57
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
58
+
59
+ // The three refresh strategies `candidate_refresh` accepts, mirroring the admin
60
+ // panel's one-click resolver (keep-mine / keep-current / smart-merge). `merge` is the
61
+ // DEFAULT: a real forge three-way merge that refuses cleanly (with the diverged file
62
+ // list) on a genuine same-line overlap rather than silently clobbering either side —
63
+ // exactly the automate-first posture (auto-resolve where safe, name the conflict when
64
+ // not, never a raw rebase instruction).
65
+ export const REFRESH_STRATEGIES = ["ours", "theirs", "merge"];
66
+ const DEFAULT_REFRESH_STRATEGY = "merge";
44
67
 
45
68
  const USAGE = `tot accept — queue a PR's integration into the preview aggregate (alias: tot merge)
46
69
 
@@ -57,6 +80,11 @@ const USAGE = `tot accept — queue a PR's integration into the preview aggregat
57
80
  exact plan and asks for an explicit confirm. There is no default-yes; a non-TTY
58
81
  without --yes is refused rather than silently proceeding.
59
82
 
83
+ If the preview branch has moved under your change so it is no longer mergeable,
84
+ accept does NOT hand you a rebase — it offers to REBUILD the change on the current
85
+ preview tip for you. Pass --refresh to do it in one step (no local git), choosing
86
+ how to resolve any file that changed on both sides with --strategy.
87
+
60
88
  Options:
61
89
  --tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
62
90
  current checkout's tenant when run inside one.
@@ -65,7 +93,15 @@ Options:
65
93
  --change-id <id> Target a specific change record instead of a PR number.
66
94
  --head-sha <sha> Optional expected PR head sha (expectedHeadSha) — an
67
95
  optimistic-concurrency guard against a PR that moved.
96
+ --refresh If the candidate isn't mergeable, rebuild it on the current
97
+ preview tip (via candidate_refresh) and then integrate — no
98
+ local rebase. Needs your \`tot login\` sign-in + promote access.
99
+ --strategy <s> How --refresh resolves a file changed on BOTH sides:
100
+ "merge" (real 3-way merge, refuses on a genuine conflict —
101
+ the default), "ours" (keep your version), "theirs" (keep
102
+ preview's version).
68
103
  --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
104
+ --mcp <url> MCP base URL for --refresh (default: env TOT_MCP_URL)
69
105
  --secret <s> operator secret (prefer the env vars below)
70
106
  --yes, -y Skip the interactive confirm (still an explicit human
71
107
  affirmative — there is no default-yes).
@@ -82,8 +118,11 @@ export function parseAcceptArgs(argv) {
82
118
  changeId: null,
83
119
  headSha: null,
84
120
  url: null,
121
+ mcp: null,
85
122
  secret: null,
86
123
  identity: null,
124
+ refresh: false,
125
+ strategy: null,
87
126
  yes: false,
88
127
  help: false,
89
128
  };
@@ -94,8 +133,11 @@ export function parseAcceptArgs(argv) {
94
133
  else if (t === "--change-id") a.changeId = argv[++i];
95
134
  else if (t === "--head-sha" || t === "--head") a.headSha = argv[++i];
96
135
  else if (t === "--url") a.url = argv[++i];
136
+ else if (t === "--mcp") a.mcp = argv[++i];
97
137
  else if (t === "--secret") a.secret = argv[++i];
98
138
  else if (t === "--identity") a.identity = argv[++i];
139
+ else if (t === "--refresh") a.refresh = true;
140
+ else if (t === "--strategy") a.strategy = argv[++i];
99
141
  else if (t === "--yes" || t === "-y") a.yes = true;
100
142
  else if (t === "--help" || t === "-h") a.help = true;
101
143
  }
@@ -128,6 +170,255 @@ export function normalizeIntegrateResponse(data) {
128
170
  };
129
171
  }
130
172
 
173
+ /** A human label for a candidate: its PR number when known, else its changeId. Pure. */
174
+ function candidateLabel(pr, changeId) {
175
+ return pr != null ? `PR #${pr}` : `change ${changeId}`;
176
+ }
177
+
178
+ /**
179
+ * Read the forge `mergeable` verdict for a candidate out of a `GET /api/changes`
180
+ * body (the operator queue — its BUILT entries carry the full ReviewEnvironment
181
+ * shape, incl. `mergeable`). This is the mergeable PREFLIGHT source: one read over the
182
+ * SAME operator-secret transport accept already uses, so a not-mergeable candidate is
183
+ * caught BEFORE a doomed integrate round-trips to the forge. Best-effort by design —
184
+ * `known:false` (proceed as normal) whenever the candidate isn't found or isn't built
185
+ * yet (no `mergeable`), so this never turns a transient listing gap into a false
186
+ * refusal. Pure — unit-tested.
187
+ * @param {any} data
188
+ * @param {{ pr:number|null, changeId:string|null }} target
189
+ * @returns {{ known:boolean, mergeable:boolean|null, changeId:string|null, prNumber:number|null }}
190
+ */
191
+ export function readCandidateVerdict(data, { pr, changeId }) {
192
+ const list = Array.isArray(data)
193
+ ? data
194
+ : data && Array.isArray(data.changes)
195
+ ? data.changes
196
+ : [];
197
+ const wantPr =
198
+ typeof pr === "number"
199
+ ? pr
200
+ : pr != null && `${pr}`.trim() && Number.isFinite(Number(pr))
201
+ ? Number(pr)
202
+ : null;
203
+ const wantId = typeof changeId === "string" && changeId ? changeId : null;
204
+ const match =
205
+ list.find(
206
+ (c) =>
207
+ c &&
208
+ typeof c === "object" &&
209
+ ((wantId && c.changeId === wantId) || (wantPr != null && c.prNumber === wantPr)),
210
+ ) || null;
211
+ if (!match) return { known: false, mergeable: null, changeId: wantId, prNumber: wantPr };
212
+ const mergeable = match.mergeable === true ? true : match.mergeable === false ? false : null;
213
+ return {
214
+ known: mergeable !== null,
215
+ mergeable,
216
+ changeId: typeof match.changeId === "string" ? match.changeId : wantId,
217
+ prNumber: typeof match.prNumber === "number" ? match.prNumber : wantPr,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Normalise a `candidate_refresh` result read back over the MCP client.
223
+ * `status:"committed"` is the ONLY success (the candidate was rebuilt and is now
224
+ * mergeable); a `merge_failed` refusal carries `unresolved` (the diverged files);
225
+ * any other status is an honest refusal/error surfaced by its `message`. Reads
226
+ * defensively — a plausible field rename degrades, never crashes. Pure — unit-tested.
227
+ * @param {any} data
228
+ */
229
+ export function normalizeRefreshResult(data) {
230
+ const o = data && typeof data === "object" ? data : {};
231
+ const ok = o.status === "committed";
232
+ return {
233
+ ok,
234
+ status: typeof o.status === "string" ? o.status : ok ? "committed" : "error",
235
+ changeId: typeof o.changeId === "string" ? o.changeId : null,
236
+ prNumber: typeof o.prNumber === "number" ? o.prNumber : null,
237
+ mergeable: typeof o.mergeable === "boolean" ? o.mergeable : null,
238
+ strategy: typeof o.strategy === "string" ? o.strategy : null,
239
+ refreshedFiles: Array.isArray(o.refreshedFiles)
240
+ ? o.refreshedFiles.filter((x) => typeof x === "string")
241
+ : [],
242
+ unresolved: Array.isArray(o.unresolved)
243
+ ? o.unresolved.filter((x) => typeof x === "string")
244
+ : [],
245
+ message: typeof o.message === "string" ? o.message : null,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * The automate-first OFFER printed when a candidate isn't mergeable and --refresh was
251
+ * NOT passed: the one-click rebuild, never a raw git/rebase instruction and never the
252
+ * banned "resolve the conflict, then retry". Pure — returns the lines to print.
253
+ * @param {string} tenant
254
+ * @param {{ pr:number|null, changeId:string|null }} target
255
+ * @returns {string[]}
256
+ */
257
+ export function notMergeableOfferLines(tenant, { pr, changeId }) {
258
+ const sel = pr != null ? `--pr ${pr}` : `--change-id ${changeId}`;
259
+ const cmd = `tot accept --tenant ${tenant} ${sel}`;
260
+ return [
261
+ " Rebuild it automatically on the current preview tip — no local checkout, one command:",
262
+ ` ${cmd} --refresh smart 3-way merge (the default)`,
263
+ ` ${cmd} --refresh --strategy=ours keep your version on any clash`,
264
+ ` ${cmd} --refresh --strategy=theirs take preview's version on any clash`,
265
+ " Nothing was integrated.",
266
+ ];
267
+ }
268
+
269
+ /**
270
+ * Run `candidate_refresh` over the MCP client — the ONE-CLICK rebuild of a
271
+ * not-mergeable candidate onto the current preview tip. `candidate_refresh` has no
272
+ * operator-secret HTTP route, so this reaches it exactly as `tot pr` reaches
273
+ * `candidate_status`: an OAuth developer session (`tot login`) + `client_switch` to
274
+ * bind the tenant scope. `createClient`/`establishSession` are injected so it's
275
+ * unit-tested with no live MCP.
276
+ *
277
+ * @param {{ tenant:string, changeId:string, strategy:string, mcpUrl?:string|null,
278
+ * identity?:string|null, env?:NodeJS.ProcessEnv }} params
279
+ * @param {{ createClient?:typeof createMcpClient, establishSession?:typeof establishSession }} [deps]
280
+ * @returns {Promise<ReturnType<typeof normalizeRefreshResult> & { hint?:string|null }>}
281
+ */
282
+ export async function runRefresh(
283
+ { tenant, changeId, strategy, mcpUrl = null, identity = null, env = process.env },
284
+ deps = {},
285
+ ) {
286
+ const create = deps.createClient || createMcpClient;
287
+ const establish = deps.establishSession || establishSession;
288
+ const baseUrl = mcpUrl || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
289
+ const client = create(baseUrl);
290
+ try {
291
+ await establish(client, { env, prefer: identity || undefined });
292
+ // Bind the active tenant so candidate_refresh resolves the right scope (mirrors
293
+ // `tot pr`). Best-effort — the tool also takes `repo` explicitly.
294
+ try {
295
+ await client.callTool("client_switch", { tenant });
296
+ } catch {
297
+ /* best-effort scope bind */
298
+ }
299
+ const raw = await client.callTool("candidate_refresh", { repo: tenant, changeId, strategy });
300
+ return normalizeRefreshResult(raw);
301
+ } catch (e) {
302
+ if (e instanceof AuthUnavailableError) {
303
+ return { ...normalizeRefreshResult(null), status: "auth", message: e.message, hint: e.hint };
304
+ }
305
+ return { ...normalizeRefreshResult(null), status: "error", message: String(e?.message || e) };
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Render a `candidate_refresh` FAILURE in the automate-first house style — a genuine
311
+ * conflict names the diverged files and offers the ours/theirs escape hatch; an auth
312
+ * gap points to `tot login`; any other refusal surfaces the MCP's own message. NEVER
313
+ * prints raw git/rebase or the banned "resolve the conflict, then retry". Returns the
314
+ * process exit code (always 1). Pure given console.
315
+ */
316
+ function reportRefreshFailure(rr, { tenant, pr, changeId, strategy }) {
317
+ const sel = pr != null ? `--pr ${pr}` : `--change-id ${changeId}`;
318
+ const cmd = `tot accept --tenant ${tenant} ${sel}`;
319
+ if (rr.status === "auth") {
320
+ console.error(
321
+ fail(
322
+ "auto-refresh needs your Token of Trust sign-in",
323
+ rr.hint || "run `tot login`, then re-run with --refresh",
324
+ ),
325
+ );
326
+ return 1;
327
+ }
328
+ if (rr.status === "merge_failed") {
329
+ console.log(
330
+ `\n ✗ couldn't auto-merge ${candidateLabel(pr, changeId)} onto ${tenant}'s current preview with --strategy=${strategy}.`,
331
+ );
332
+ if (rr.unresolved.length) {
333
+ console.log(" These files changed on both sides and need your call:");
334
+ for (const f of rr.unresolved) console.log(` - ${f}`);
335
+ }
336
+ console.log(" Re-run choosing which side wins on those files:");
337
+ console.log(` ${cmd} --refresh --strategy=ours keep your version`);
338
+ console.log(` ${cmd} --refresh --strategy=theirs take preview's version`);
339
+ console.log(" Nothing was integrated.");
340
+ return 1;
341
+ }
342
+ // Any other refusal (not_found / not_open / base_not_found / a capability guard /
343
+ // an unexpected error): surface the MCP's own message honestly — never dressed up.
344
+ console.error(
345
+ fail(
346
+ rr.message || `couldn't refresh ${candidateLabel(pr, changeId)} (${rr.status})`,
347
+ "check the candidate is open and that you have promote access for this store",
348
+ ),
349
+ );
350
+ return 1;
351
+ }
352
+
353
+ /**
354
+ * Decide + act on a NOT-mergeable candidate (the automate-first fork): without
355
+ * --refresh, print the one-click offer and stop (never a rebase); with --refresh,
356
+ * rebuild via `candidate_refresh` and, on success, hand back the (possibly new) PR
357
+ * handle so the caller integrates the rebuilt candidate. Returns
358
+ * `{ integrate:false, code }` to stop, or `{ integrate:true, pr, changeId }` to
359
+ * proceed.
360
+ */
361
+ async function handleNotMergeable(
362
+ { tenant, pr, changeId, strategy, refresh, mcpUrl, identity, env },
363
+ deps,
364
+ ) {
365
+ if (!refresh) {
366
+ console.log(
367
+ `\n ✗ ${candidateLabel(pr, changeId)} isn't mergeable into ${tenant}'s preview — the preview branch moved under it.`,
368
+ );
369
+ for (const line of notMergeableOfferLines(tenant, { pr, changeId })) console.log(line);
370
+ return { integrate: false, code: 1 };
371
+ }
372
+ if (!changeId) {
373
+ // candidate_refresh keys off the stable changeId; a bare PR number we couldn't
374
+ // resolve to one (not built yet / listing gap) can't be auto-rebuilt.
375
+ console.error(
376
+ fail(
377
+ `can't auto-refresh ${candidateLabel(pr, changeId)} — its change id isn't resolved yet`,
378
+ `check it's built (\`tot pr list --tenant ${tenant}\`), or pass --change-id`,
379
+ ),
380
+ );
381
+ return { integrate: false, code: 1 };
382
+ }
383
+ const rr = await runRefresh({ tenant, changeId, strategy, mcpUrl, identity, env }, deps);
384
+ if (!rr.ok) {
385
+ return { integrate: false, code: reportRefreshFailure(rr, { tenant, pr, changeId, strategy }) };
386
+ }
387
+ console.log(
388
+ `\n ✓ rebuilt ${candidateLabel(rr.prNumber ?? pr, changeId)} on ${tenant}'s current preview (${rr.strategy ?? strategy}) — now mergeable.`,
389
+ );
390
+ if (rr.refreshedFiles.length) console.log(` reapplied: ${rr.refreshedFiles.join(", ")}`);
391
+ // The rebuild opens a FRESH PR (prNumber may change; changeId is stable), so
392
+ // integrate by the new PR number + changeId and drop the now-stale head sha.
393
+ return { integrate: true, pr: rr.prNumber ?? pr, changeId };
394
+ }
395
+
396
+ /** POST `/api/changes/integrate` and normalise the outcome. Returns { res, result }. */
397
+ async function postIntegrate({ base, authHeaders, body }, fetchImpl) {
398
+ const res = await fetchImpl(`${base}/api/changes/integrate`, {
399
+ method: "POST",
400
+ headers: { "content-type": "application/json", ...authHeaders },
401
+ body: JSON.stringify(body),
402
+ });
403
+ let data = {};
404
+ try {
405
+ data = await res.json();
406
+ } catch {
407
+ /* non-JSON / empty body */
408
+ }
409
+ return { res, result: normalizeIntegrateResponse(data) };
410
+ }
411
+
412
+ /** The integrate request body for a target. Pure. */
413
+ function integrateBody({ tenant, pr, changeId, headSha }) {
414
+ return {
415
+ repo: tenant,
416
+ ...(pr != null ? { prNumber: pr } : {}),
417
+ ...(changeId ? { changeId } : {}),
418
+ ...(headSha ? { expectedHeadSha: headSha } : {}),
419
+ };
420
+ }
421
+
131
422
  /**
132
423
  * Render the terminal aggregate status in house style — the honest queue/run state,
133
424
  * NEVER a bare "merged". Pure given its inputs; returns the process exit code.
@@ -144,6 +435,16 @@ export function reportIntegrated(result, { tenant, label }) {
144
435
  console.log(" → next: `tot ship` to promote this green aggregate live.");
145
436
  return 0;
146
437
  }
438
+ // Already integrated (candidate_not_open): the PR merged before this attempt —
439
+ // a SUCCESS the operator is re-hearing, not a failure. Never render the
440
+ // fix-and-retry template for it (live confusion, 2026-08-20: it told an
441
+ // operator to re-submit a change that had already landed). Exit 0 — the
442
+ // desired end state ("this change is in the aggregate") already holds.
443
+ if (result.reason === "candidate_not_open") {
444
+ console.log(`\n ✓ ${label} was ALREADY integrated into ${tenant}'s preview aggregate — nothing left to accept.`);
445
+ console.log(" → next: `tot ship` to promote the aggregate live, or `tot revert` to pull the change back out.");
446
+ return 0;
447
+ }
147
448
  // Honest non-green: the candidate did NOT land in the shippable aggregate.
148
449
  console.log(`\n ✗ ${label} did NOT integrate into ${tenant}'s preview aggregate.`);
149
450
  console.log(` aggregate: ${state}${result.reason ? ` (${result.reason})` : ""}`);
@@ -159,25 +460,43 @@ export function reportIntegrated(result, { tenant, label }) {
159
460
  * unit-tested with no network/TTY.
160
461
  *
161
462
  * @param {{ tenant:string, pr:number|null, changeId:string|null, headSha:string|null,
162
- * secret:string, storefrontUrl?:string|null, yes?:boolean }} params
163
- * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm }} [deps]
463
+ * secret:string, storefrontUrl?:string|null, yes?:boolean, refresh?:boolean,
464
+ * strategy?:string, mcpUrl?:string|null, identity?:string|null, env?:NodeJS.ProcessEnv }} params
465
+ * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
466
+ * resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
164
467
  * @returns {Promise<number>} process exit code
165
468
  */
166
469
  export async function runIntegrate(
167
- { tenant, pr, changeId, headSha, secret, storefrontUrl = null, yes = false },
470
+ {
471
+ tenant,
472
+ pr,
473
+ changeId,
474
+ headSha,
475
+ secret,
476
+ storefrontUrl = null,
477
+ yes = false,
478
+ refresh = false,
479
+ strategy = DEFAULT_REFRESH_STRATEGY,
480
+ mcpUrl = null,
481
+ identity = null,
482
+ env = process.env,
483
+ },
168
484
  deps = {},
169
485
  ) {
170
486
  const fetchImpl = deps.fetch || globalThis.fetch;
171
487
  const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
172
- const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
488
+ const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
489
+ let base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
173
490
  const label = pr != null ? `PR #${pr}` : changeId;
174
491
 
175
- // 1. State the EXACT plan (shared U10 affordance) — queue-integrate-into-preview,
492
+ // 1. State the EXACT plan (shared affordance) — queue-integrate-into-preview,
176
493
  // NO merge, NO go-live — and gate on an explicit confirm.
177
494
  const planLines = planForAction({ action: "accept", tenant, pr, changeId, headSha });
178
495
  const { confirmed, reason } = await confirmPlan(planLines, {
179
496
  yes,
180
- question: `Queue ${label} for integration into ${tenant}'s preview aggregate?`,
497
+ question: refresh
498
+ ? `Queue ${label} for integration into ${tenant}'s preview aggregate (rebuilding it first if the preview moved)?`
499
+ : `Queue ${label} for integration into ${tenant}'s preview aggregate?`,
181
500
  });
182
501
  if (!confirmed) {
183
502
  if (reason === "non-tty") {
@@ -193,38 +512,75 @@ export async function runIntegrate(
193
512
  return 1;
194
513
  }
195
514
 
196
- // 2. Operator-secret transport — the CLI holds no storefront cookie, so the
197
- // Bearer + X-Tot-Owner path is its route (same as `tot ship --pr`).
198
- if (!secret) {
199
- console.error(
200
- fail(
201
- "integrating a PR is an OPERATOR action it needs an operator secret",
202
- "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
203
- ),
204
- );
205
- return 2;
515
+ // 2. Resolve the transport. Two routes to the SAME `/api/changes/integrate`:
516
+ // - OPERATOR SECRET (operators/CI): Bearer + X-Tot-Owner on the generic host.
517
+ // - VIEWER SESSION (an invited developer, no secret): mint a `tot_session` from
518
+ // their OWN `tot` login on the TENANT'S host and send it as a cookie. The
519
+ // server authorizes on their live ship-on-behalf grant either way.
520
+ // No `content-type` here: it's added per-POST; the mergeable preflight GET wants none.
521
+ let authHeaders;
522
+ if (secret) {
523
+ authHeaders = {
524
+ authorization: `Bearer ${secret}`,
525
+ "x-tot-owner": tenant,
526
+ "x-tot-capability": "ship-on-behalf",
527
+ };
528
+ } else {
529
+ const viewer = await resolveViewer({ tenant, env, fetchImpl });
530
+ if (!viewer.ok) {
531
+ console.error(fail(viewer.message, viewer.hint));
532
+ return 2;
533
+ }
534
+ base = viewer.base; // the tenant's own host — the dev-viewer admission is host-scoped
535
+ authHeaders = viewer.authHeaders;
206
536
  }
207
- const authHeaders = {
208
- "content-type": "application/json",
209
- authorization: `Bearer ${secret}`,
210
- "x-tot-owner": tenant,
211
- "x-tot-capability": "ship-on-behalf",
212
- };
213
- const requestBody = {
214
- repo: tenant,
215
- ...(pr != null ? { prNumber: pr } : {}),
216
- ...(changeId ? { changeId } : {}),
217
- ...(headSha ? { expectedHeadSha: headSha } : {}),
537
+
538
+ // Auto-surface the merge-doctor on a failed accept. Best-effort: fetches the
539
+ // hosted doctor over the SAME transport we just resolved and prints the COMPACT
540
+ // summary; never throws and never touches the exit code (it's called AFTER the
541
+ // outcome is decided, purely to append diagnostic context to where the failure
542
+ // already surfaced). A clean/unreachable doctor prints nothing.
543
+ const surfaceDoctor = async () => {
544
+ for (const line of await autoSurfaceDoctor({ base, authHeaders, fetchImpl })) console.error(line);
218
545
  };
219
546
 
220
- // 3. POST the honest accept path (b07 queue enqueue) and render the terminal state.
547
+ // 2.5. MERGEABLE PREFLIGHT read the candidate's forge verdict from the queue
548
+ // (`GET /api/changes`, SAME transport) so a doomed accept never round-trips to
549
+ // the forge. Best-effort: on any listing gap it's `known:false` → proceed as
550
+ // normal. When it KNOWS the candidate isn't mergeable, take the automate-first
551
+ // fork (offer/do --refresh) instead of a raw rebase.
552
+ let curPr = pr;
553
+ let curChangeId = changeId;
554
+ let curHead = headSha;
555
+ const verdict = await preflightMergeable({ base, authHeaders, pr, changeId }, fetchImpl);
556
+ if (verdict.mergeable === false) {
557
+ const handled = await handleNotMergeable(
558
+ {
559
+ tenant,
560
+ pr: verdict.prNumber ?? curPr,
561
+ changeId: verdict.changeId ?? curChangeId,
562
+ strategy,
563
+ refresh,
564
+ mcpUrl,
565
+ identity,
566
+ env,
567
+ },
568
+ deps,
569
+ );
570
+ if (!handled.integrate) return /** @type {number} */ (handled.code);
571
+ curPr = handled.pr ?? curPr;
572
+ curChangeId = handled.changeId ?? curChangeId;
573
+ curHead = null; // the rebuilt PR has a fresh head; let the server re-resolve.
574
+ }
575
+
576
+ // 3. POST the honest accept path (queue enqueue) and render the terminal state.
221
577
  let res;
578
+ let result;
222
579
  try {
223
- res = await fetchImpl(`${base}/api/changes/integrate`, {
224
- method: "POST",
225
- headers: authHeaders,
226
- body: JSON.stringify(requestBody),
227
- });
580
+ ({ res, result } = await postIntegrate(
581
+ { base, authHeaders, body: integrateBody({ tenant, pr: curPr, changeId: curChangeId, headSha: curHead }) },
582
+ fetchImpl,
583
+ ));
228
584
  } catch (e) {
229
585
  console.error(
230
586
  fail(`couldn't reach the integration queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
@@ -232,32 +588,98 @@ export async function runIntegrate(
232
588
  return 1;
233
589
  }
234
590
 
235
- let data = {};
236
- try {
237
- data = await res.json();
238
- } catch {
239
- /* non-JSON / empty body */
240
- }
241
- const result = normalizeIntegrateResponse(data);
242
-
243
591
  // A pre-flight error (auth, unknown tenant, candidate not found) is an HTTP 4xx
244
592
  // with `{ error }` and no queue verdict — surface it distinctly from a red run.
245
593
  if (!res.ok && result.queueState == null) {
246
- const msg = result.error || `HTTP ${res.status}`;
247
- console.error(
248
- fail(
249
- `the integration queue refused the request: ${msg}`,
250
- res.status === 401 || res.status === 403
251
- ? "check the operator secret and that it's authorised for this tenant"
252
- : res.status === 404
253
- ? `check that ${label} has a built candidate in ${tenant}'s queue (\`tot pr list --tenant ${tenant}\`)`
254
- : "check --tenant / --url / --pr, then re-run",
255
- ),
594
+ return reportIntegrateHttpError(result, res, { tenant, label });
595
+ }
596
+
597
+ // 4. A not_mergeable outcome the preflight MISSED (listing gap / a race between
598
+ // read and enqueue): take the same automate-first fork rather than the generic
599
+ // "fix the candidate" copy. Guarded by `!verdict.known` so a candidate the
600
+ // preflight already routed through --refresh can't loop here.
601
+ if (!result.ok && result.reason === "not_mergeable" && !verdict.known) {
602
+ const handled = await handleNotMergeable(
603
+ { tenant, pr: curPr, changeId: result.changeId ?? curChangeId, strategy, refresh, mcpUrl, identity, env },
604
+ deps,
256
605
  );
606
+ if (!handled.integrate) return /** @type {number} */ (handled.code);
607
+ try {
608
+ ({ res, result } = await postIntegrate(
609
+ {
610
+ base,
611
+ authHeaders,
612
+ body: integrateBody({
613
+ tenant,
614
+ pr: handled.pr ?? curPr,
615
+ changeId: handled.changeId ?? curChangeId,
616
+ headSha: null,
617
+ }),
618
+ },
619
+ fetchImpl,
620
+ ));
621
+ } catch (e) {
622
+ console.error(
623
+ fail(`couldn't reach the integration queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
624
+ );
625
+ return 1;
626
+ }
627
+ if (!res.ok && result.queueState == null) {
628
+ return reportIntegrateHttpError(result, res, { tenant, label });
629
+ }
630
+ }
631
+
632
+ // A not_mergeable that survived a refresh attempt (or arrived with --refresh unset
633
+ // on the preflight-known path): the honest one-click offer, never a raw rebase.
634
+ if (!result.ok && result.reason === "not_mergeable") {
635
+ console.log(
636
+ `\n ✗ ${label} still isn't mergeable into ${tenant}'s preview.`,
637
+ );
638
+ for (const line of notMergeableOfferLines(tenant, { pr: curPr, changeId: curChangeId })) {
639
+ console.log(line);
640
+ }
641
+ await surfaceDoctor();
257
642
  return 1;
258
643
  }
259
644
 
260
- return reportIntegrated(result, { tenant, label });
645
+ const code = reportIntegrated(result, { tenant, label: /** @type {string} */ (label) });
646
+ if (code !== 0) await surfaceDoctor();
647
+ return code;
648
+ }
649
+
650
+ /** The mergeable PREFLIGHT read (best-effort). See readCandidateVerdict. */
651
+ async function preflightMergeable({ base, authHeaders, pr, changeId }, fetchImpl) {
652
+ try {
653
+ const res = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
654
+ if (!res || !res.ok) {
655
+ return { known: false, mergeable: null, changeId: changeId ?? null, prNumber: pr ?? null };
656
+ }
657
+ let data = {};
658
+ try {
659
+ data = await res.json();
660
+ } catch {
661
+ return { known: false, mergeable: null, changeId: changeId ?? null, prNumber: pr ?? null };
662
+ }
663
+ return readCandidateVerdict(data, { pr, changeId });
664
+ } catch {
665
+ return { known: false, mergeable: null, changeId: changeId ?? null, prNumber: pr ?? null };
666
+ }
667
+ }
668
+
669
+ /** Surface an integrate HTTP pre-flight error (4xx, no queue verdict). Returns exit 1. */
670
+ function reportIntegrateHttpError(result, res, { tenant, label }) {
671
+ const msg = result.error || `HTTP ${res.status}`;
672
+ console.error(
673
+ fail(
674
+ `the integration queue refused the request: ${msg}`,
675
+ res.status === 401 || res.status === 403
676
+ ? "you need a live ship-on-behalf grant on this tenant (ask the store owner) — or an operator secret authorised for it"
677
+ : res.status === 404
678
+ ? `check that ${label} has a built candidate in ${tenant}'s queue (\`tot pr list --tenant ${tenant}\`)`
679
+ : "check --tenant / --url / --pr, then re-run",
680
+ ),
681
+ );
682
+ return 1;
261
683
  }
262
684
 
263
685
  /**
@@ -301,6 +723,18 @@ export async function run(argv, ctx) {
301
723
  const storefrontUrl =
302
724
  args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
303
725
 
726
+ // --strategy only means something with --refresh; validate its value whenever given.
727
+ const strategy = (args.strategy || DEFAULT_REFRESH_STRATEGY).trim();
728
+ if (args.strategy != null && !REFRESH_STRATEGIES.includes(strategy)) {
729
+ console.error(
730
+ fail(
731
+ `unknown --strategy "${args.strategy}"`,
732
+ `use one of: ${REFRESH_STRATEGIES.join(", ")} (default "${DEFAULT_REFRESH_STRATEGY}")`,
733
+ ),
734
+ );
735
+ return 2;
736
+ }
737
+
304
738
  return await runIntegrate({
305
739
  tenant,
306
740
  pr,
@@ -309,5 +743,10 @@ export async function run(argv, ctx) {
309
743
  secret: resolveOperatorSecret(args.secret, env),
310
744
  storefrontUrl,
311
745
  yes: args.yes,
746
+ refresh: args.refresh,
747
+ strategy,
748
+ mcpUrl: args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL,
749
+ identity: args.identity || null,
750
+ env,
312
751
  });
313
752
  }