@tokenoftrust/cli 1.4.0-rc.14 → 1.4.0-rc.16

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.
package/bin/tot.mjs CHANGED
@@ -14,10 +14,13 @@
14
14
  * tot validate lint your store before you submit ← built
15
15
  * tot dev run your store locally with save→reload ← built (monorepo: host astro; standalone: runs the published runner image)
16
16
  * tot preview push your store to a reviewable preview ← built (validate + push preview ref; MCP preview_status read-back). `submit`/`deploy` are teaching aliases.
17
- * tot ship promote a reconciled preview live ← built (diff-vs-live + y/N confirm change_accept; refuses non-TTY / unreconciled)
18
- * tot accept / tot merge merge a PR into main (operator verb) ← built (plan + y/N confirm → the SAME change_accept ship uses; no deploy; refuses non-TTY without --yes)
17
+ * tot ship publish the current green aggregate live ← built (GET/POST /api/changes/ship → b09 orchestrator; exact plan + y/N confirm; waits for VERIFIED live truth)
18
+ * tot accept / tot merge queue a PR into the preview aggregate ← built (b08: plan + y/N confirm → POST /api/changes/integrate = b07 queue enqueue; NO merge-to-main, NO go-live; refuses non-TTY without --yes)
19
+ * tot sync fetch `preview` + merge it into your branch ← built (b16: the common accept-conflict recovery path; local-only, stops safely on conflict)
19
20
  * tot rollback [<version>] instant re-point to a prior live version ← built (u3 promotion_status/promotion_rollback seam; diff + y/N confirm; refuses non-TTY / ineligible)
20
21
  * tot pr list / view / close your candidate PRs ← built (candidate_status/candidate_close; gh-pr-shaped)
22
+ * tot branches full branch-cleanup report (every branch, not just open PRs) ← built (b14: candidate_list)
23
+ * tot cleanup owner-confirmed branch GC (deletes terminal branches only) ← built (b14: candidate_list + candidate_delete; --dry-run classifies only)
21
24
  * tot doctor check this machine is ready
22
25
  * tot ideas copy-paste AI prompts that reliably wow
23
26
  * tot feedback send a note to ToT + your recent CLI activity ← built (activity-log.mjs → feedback_submit MCP tool)
@@ -64,12 +67,17 @@ tot — Token of Trust developer CLI
64
67
  tot validate lint your store before you submit
65
68
  tot dev run your store locally with save→reload
66
69
  tot preview push your store to a reviewable preview
67
- tot ship promote a reconciled preview live (diff → confirm → ship)
68
- tot accept / tot merge merge a PR into main — operator verb, no deploy (plan → confirm → merge)
70
+ tot ship publish the tenant's current green aggregate live (plan → confirm → ship)
71
+ tot accept / tot merge queue a PR into the preview aggregate — operator verb, no go-live (plan → confirm → integrate)
72
+ tot sync fetch \`preview\` and merge it into your local branch (accept-conflict recovery)
69
73
  tot rollback [<version>] instant re-point to a prior live version (list → confirm → rollback)
74
+ tot revert --preview <PR|sha> remove already-integrated content from the preview aggregate via a new revert commit — no force-reset (plan → confirm → revert)
75
+ tot hotfix --pr <N> OWNER-ONLY exception: release an urgent fix from main to live, bypassing unshipped preview work (plan → confirm → release → auto forward-integrate)
70
76
  tot retire evict a candidate PR's preview to reclaim space — operator verb, rebuildable (plan → confirm → evict)
71
77
  tot go-live cut the apex domain over to the storefront (readiness → confirm → cutover)
72
78
  tot pr list / view / close your candidate PRs
79
+ tot branches full branch-cleanup report — every branch, any PR state, cleanup eligibility
80
+ tot cleanup owner-confirmed branch GC (--dry-run to classify only; deletes terminal branches only)
73
81
  tot doctor check this machine is ready
74
82
  tot ideas copy-paste AI prompts that reliably wow
75
83
  tot feedback "<msg>" send feedback to Token of Trust (attaches recent activity)
@@ -174,20 +182,52 @@ async function dispatch(cmd, rest, ctx) {
174
182
  return run(rest, ctx);
175
183
  }
176
184
 
177
- // `accept` merges a PR into main (no deploy) — a DISTINCT operator verb from
178
- // `ship`, backed by the SAME change_accept pipeline (see accept.mjs's header).
179
- // `merge` is a first-class alias, not a teaching nudge: both dispatch straight
180
- // to the same command.
185
+ // `accept` queue-integrates a PR into the protected `preview` aggregate (b08) —
186
+ // NO merge-to-main, NO go-live; going live is `tot ship`, which publishes the
187
+ // whole current GREEN AGGREGATE (b10). Backed by POST /api/changes/integrate
188
+ // (b07's TenantIntegrationQueue.enqueue); see accept.mjs's header. `merge` is a
189
+ // first-class alias, not a teaching nudge: both dispatch straight to the same command.
181
190
  if (cmd === "accept" || cmd === "merge") {
182
191
  const { run } = await import("../src/commands/accept.mjs");
183
192
  return run(rest, ctx);
184
193
  }
185
194
 
195
+ // `sync` is the common accept-conflict recovery path (P1 item 12): fetches the
196
+ // protected `preview` branch and merges it into the developer's local branch so
197
+ // they can resolve locally, then re-`tot preview`. Purely local — no push, no
198
+ // MCP call, never touches the shared `preview`/`main` refs themselves. See
199
+ // sync.mjs's header for the full contract.
200
+ if (cmd === "sync") {
201
+ const { run } = await import("../src/commands/sync.mjs");
202
+ return run(rest, ctx);
203
+ }
204
+
186
205
  if (cmd === "rollback") {
187
206
  const { run } = await import("../src/commands/rollback.mjs");
188
207
  return run(rest, ctx);
189
208
  }
190
209
 
210
+ // `revert --preview <PR|integration-sha>` removes already-integrated content from
211
+ // the protected `preview` aggregate via a NEW auditable revert commit (b21) — NO
212
+ // force-reset, NO touch to main/live. Backed by POST /api/changes/revert (b07's
213
+ // TenantIntegrationQueue.enqueueRevert). To undo something already LIVE, that's
214
+ // `tot rollback`, not this. See revert.mjs's header.
215
+ if (cmd === "revert") {
216
+ const { run } = await import("../src/commands/revert.mjs");
217
+ return run(rest, ctx);
218
+ }
219
+
220
+ // `hotfix --pr <N>` is the OWNER-ONLY EXCEPTION lane (b22): release an urgent fix
221
+ // from `main` to live while `preview` still holds other unshipped work, EXCLUDING
222
+ // that unshipped preview head, then automatically forward-integrate main → preview
223
+ // and re-validate. It is DELIBERATELY a distinct verb — never a `--base main` flag
224
+ // on `tot ship` (which publishes the whole green preview aggregate). Backed by
225
+ // GET/POST /api/changes/hotfix → b22's HotfixOrchestrator. See hotfix.mjs's header.
226
+ if (cmd === "hotfix") {
227
+ const { run } = await import("../src/commands/hotfix.mjs");
228
+ return run(rest, ctx);
229
+ }
230
+
191
231
  // `retire` evicts a candidate PR's hosted preview to reclaim space (unit U7) —
192
232
  // a DISTINCT operator verb from `accept`/reject: retire touches no change
193
233
  // lifecycle and is reversible-by-rebuild (`tot preview build`). See retire.mjs.
@@ -206,6 +246,24 @@ async function dispatch(cmd, rest, ctx) {
206
246
  return run(rest, ctx);
207
247
  }
208
248
 
249
+ // `branches` is the FULL branch-cleanup report (every branch, any PR state) —
250
+ // distinct from `tot pr list`, which only shows OPEN PRs (P1 item 9). Read-only;
251
+ // always safe to run, and the report `tot cleanup` reuses before deleting anything.
252
+ if (cmd === "branches") {
253
+ const { run } = await import("../src/commands/branches.mjs");
254
+ return run(rest, ctx);
255
+ }
256
+
257
+ // `cleanup` is owner-confirmed BRANCH GC (P1 items 9/10) — an irreversible git-ref
258
+ // delete, distinct from `tot retire`'s hosted-preview eviction (rebuildable, no
259
+ // branch touched). `--dry-run` classifies and prints; it never deletes. Age alone
260
+ // never makes a branch eligible; main/preview are always kept; orphans are
261
+ // quarantined, never auto-deleted. See cleanup.mjs's header.
262
+ if (cmd === "cleanup") {
263
+ const { run } = await import("../src/commands/cleanup.mjs");
264
+ return run(rest, ctx);
265
+ }
266
+
209
267
  if (cmd === "app") {
210
268
  const { run } = await import("../src/commands/app/index.mjs");
211
269
  return run(rest, ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.4.0-rc.14",
3
+ "version": "1.4.0-rc.16",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -1,84 +1,78 @@
1
1
  /**
2
- * `tot accept --tenant <t> --pr <N>` (alias: `tot merge`) — OPERATOR verb: MERGE
3
- * a PR into main. DISTINCT from `tot ship`: per board decision
4
- * `operator-verb-and-hosting-model`, accepting a PR merges it into main; main
5
- * becomes default-hosted on preview + live only after the NEXT `tot ship` /
6
- * deploy accept itself does not deploy. (`tot ship` run by the developer in
7
- * their own checkout stays accept-THEN-deploy in one gated step — see
8
- * `ship-context-dependent-semantics` in `../plan.mjs`; this verb is the
9
- * explicit, deploy-free half of that, for an operator who just wants the merge.)
2
+ * `tot accept --tenant <t> --pr <N>` (alias: `tot merge`) — OPERATOR verb: QUEUE a
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
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
7
+ * `candidate_accept`, preview-base only), rebuilds the aggregate, and moves the
8
+ * shared preview pointer ONLY when combined evidence is green.
10
9
  *
11
- * TRANSPORT reuses the EXISTING accept pipeline `tot ship` already drives over
12
- * MCP (merge reconcile promote verify): the SAME `change_accept` tool,
13
- * the SAME session/auth pattern (`createMcpClient` + `establishSession`), and
14
- * the SAME result normaliser/poll (`normalizeChangeResult` / `pollChangeShipped`,
15
- * imported straight from `./ship.mjs` rather than re-implemented). No new
16
- * transport, no new wire contract.
10
+ * DISTINCT from `tot ship`: accept is NOT go-live. It touches NO `main` and NO live
11
+ * channel; a green aggregate is promoted live only by a later `tot ship`. And it is
12
+ * NOT `tot preview build` (which materializes ONE candidate's own preview in
13
+ * isolation) — accept lands the candidate into the SHARED aggregate other reviewers
14
+ * see.
17
15
  *
18
- * TARGET RESOLUTION — `--pr N --tenant t` names the PR, but there is no
19
- * PR→change-record lookup on the wire, and even if there were, this CLI's own
20
- * identity may not have cross-tenant forge read access to resolve one for a PR
21
- * that isn't the developer's own (`resolveChangeRecordId` in ship.mjs only works
22
- * because it reads the DEVELOPER'S OWN local git HEAD — an operator targeting an
23
- * arbitrary PR has no local checkout of it to read). So this verb requires the
24
- * change-RECORD id explicitly via `--change-id` (the `chg_<uuid>` a prior
25
- * change_open/change_ready minted — e.g. from the PR author's own `tot ship`
26
- * review step, or the `/admin` confirm flow) and an optional `--head-sha`
27
- * (passed through as `expectedHeadSha` — an optimistic-concurrency guard so
28
- * accept refuses if the PR moved since the operator looked at it).
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
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.
29
20
  *
30
- * // u9: auto-resolve PR→changeId via forge PR-read (candidate_status), so an
31
- * // operator who DOES have forge access can omit --change-id and pass --pr
32
- * // alone. Not implemented here deliberately out of scope for this unit.
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
24
+ * transport (`resolveOperatorSecret` + `X-Tot-Owner` + `x-tot-capability`), since the
25
+ * CLI holds no storefront cookie. The response is the honest `IntegrateOutcome` —
26
+ * `queueState` / `runState` / `pointerMoved` / `aggregateSha` / `statusMessage` —
27
+ * which this verb renders VERBATIM, never a bare "merged".
33
28
  *
34
- * HUMAN GATE — merging to main is a decision a human makes at the keyboard, so
35
- * this verb ALWAYS states the EXACT plan (shared `planForAction`, unit U10:
36
- * which PR, which tenant, "merge into main", plus a note that live/preview
37
- * don't move until the next ship/deploy) and requires an explicit confirm.
38
- * `--yes` is still an explicit affirmative supplied on the command line — there
39
- * is deliberately NO default-yes, and a non-TTY without `--yes` is refused
40
- * rather than silently proceeding (mirrors `tot ship`'s non-TTY refusal).
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,
31
+ * which tenant, "queue for integration into the preview aggregate NO merge, NO
32
+ * go-live") and requires an explicit confirm. `--yes` is an explicit affirmative; a
33
+ * non-TTY without `--yes` is refused (mirrors `tot ship`'s non-TTY refusal).
41
34
  *
42
- * Dependency-free (the MCP client + the shared plan module).
35
+ * Dependency-free (global fetch + the shared plan module).
43
36
  */
44
- import { createMcpClient } from "../mcp.mjs";
45
- import { establishSession, AuthUnavailableError } from "../auth.mjs";
46
37
  import { fail } from "../errors.mjs";
47
38
  import { planForAction, printPlanAndConfirm } from "../plan.mjs";
48
- import { normalizeChangeResult, pollChangeShipped } from "./ship.mjs";
39
+ // Reuse `tot ship`'s operator-secret precedence verbatim so accept + ship + pr-list
40
+ // speak ONE operator-auth contract, not three.
41
+ import { resolveOperatorSecret } from "./ship.mjs";
49
42
 
50
- const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
43
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
51
44
 
52
- const USAGE = `tot accept — merge a PR into main (alias: tot merge)
45
+ const USAGE = `tot accept — queue a PR's integration into the preview aggregate (alias: tot merge)
53
46
 
54
- tot accept --tenant <t> --pr <N> --change-id <chg_...>
55
- tot merge --tenant <t> --pr <N> --change-id <chg_...> (same command)
47
+ tot accept --tenant <t> --pr <N>
48
+ tot merge --tenant <t> --pr <N> (same command)
56
49
 
57
- Merges the given PR into main via the existing accept pipeline (merge →
58
- reconcile promote verify) the SAME gate \`tot ship\` uses. Distinct
59
- from \`tot ship\`: accepting does NOT deploy. Main becomes default-hosted on
60
- preview + live only after the next \`tot ship\` / deploy.
50
+ Queues the given PR for integration into the tenant's protected \`preview\`
51
+ aggregate: the serialized queue merges it into \`preview\` (preview-base only),
52
+ rebuilds the aggregate, runs combined evidence, and moves the shared preview
53
+ pointer ONLY when green. Accepting does NOT merge to main and does NOT go live —
54
+ a green aggregate is promoted live later by \`tot ship\`.
61
55
 
62
- Merge-to-main is a human decision: this ALWAYS prints the exact plan and asks
63
- for an explicit confirm. There is no default-yes; a non-TTY without --yes is
64
- refused rather than silently proceeding.
56
+ Integrating into the shared preview is a human decision: this ALWAYS prints the
57
+ exact plan and asks for an explicit confirm. There is no default-yes; a non-TTY
58
+ without --yes is refused rather than silently proceeding.
65
59
 
66
60
  Options:
67
61
  --tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
68
62
  current checkout's tenant when run inside one.
69
- --pr <N> PR number to merge (for the plan text / labeling).
70
- --change-id <id> The change-record id (chg_...) to accept. REQUIRED —
71
- there is no PR→changeId auto-resolution yet (u9); pass
72
- the id a prior change_open/change_ready minted (e.g.
73
- from the author's own \`tot ship\` review step, or the
74
- /admin confirm flow).
63
+ --pr <N> PR number to integrate. The server resolves it to its
64
+ candidate from the tenant's queue (no --change-id needed).
65
+ --change-id <id> Target a specific change record instead of a PR number.
75
66
  --head-sha <sha> Optional expected PR head sha (expectedHeadSha) — an
76
67
  optimistic-concurrency guard against a PR that moved.
77
- --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
78
- --identity <id> sign in as a specific identity for this accept
68
+ --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
69
+ --secret <s> operator secret (prefer the env vars below)
79
70
  --yes, -y Skip the interactive confirm (still an explicit human
80
71
  affirmative — there is no default-yes).
81
- --help, -h Show this help.`;
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).`;
82
76
 
83
77
  /** Parse `tot accept` / `tot merge` argv. Pure — unit-testable. */
84
78
  export function parseAcceptArgs(argv) {
@@ -87,7 +81,8 @@ export function parseAcceptArgs(argv) {
87
81
  pr: null,
88
82
  changeId: null,
89
83
  headSha: null,
90
- mcp: null,
84
+ url: null,
85
+ secret: null,
91
86
  identity: null,
92
87
  yes: false,
93
88
  help: false,
@@ -98,7 +93,8 @@ export function parseAcceptArgs(argv) {
98
93
  else if (t === "--pr") a.pr = argv[++i];
99
94
  else if (t === "--change-id") a.changeId = argv[++i];
100
95
  else if (t === "--head-sha" || t === "--head") a.headSha = argv[++i];
101
- else if (t === "--mcp") a.mcp = argv[++i];
96
+ else if (t === "--url") a.url = argv[++i];
97
+ else if (t === "--secret") a.secret = argv[++i];
102
98
  else if (t === "--identity") a.identity = argv[++i];
103
99
  else if (t === "--yes" || t === "-y") a.yes = true;
104
100
  else if (t === "--help" || t === "-h") a.help = true;
@@ -106,22 +102,162 @@ export function parseAcceptArgs(argv) {
106
102
  return a;
107
103
  }
108
104
 
109
- /** Report the accept result in house style. Pure given its inputs. */
110
- function reportAccepted(accept, status, { tenant, pr, changeId }) {
105
+ /**
106
+ * Normalise a `POST /api/changes/integrate` body to the honest terminal aggregate
107
+ * fields this verb renders. Reads defensively so a plausible field rename degrades
108
+ * rather than crashes. Pure — unit-tested.
109
+ * @param {any} data
110
+ * @returns {{ ok:boolean, queueState:string|null, runState:string|null,
111
+ * pointerMoved:boolean, aggregateSha:string|null, statusMessage:string|null,
112
+ * reason:string|null, changeId:string|null, prNumber:number|null, error:string|null, raw:any }}
113
+ */
114
+ export function normalizeIntegrateResponse(data) {
115
+ const o = data && typeof data === "object" ? data : {};
116
+ return {
117
+ ok: o.ok === true,
118
+ queueState: typeof o.queueState === "string" ? o.queueState : null,
119
+ runState: typeof o.runState === "string" ? o.runState : null,
120
+ pointerMoved: o.pointerMoved === true,
121
+ aggregateSha: typeof o.aggregateSha === "string" ? o.aggregateSha : null,
122
+ statusMessage: typeof o.statusMessage === "string" ? o.statusMessage : null,
123
+ reason: typeof o.reason === "string" ? o.reason : null,
124
+ changeId: typeof o.changeId === "string" ? o.changeId : null,
125
+ prNumber: typeof o.prNumber === "number" ? o.prNumber : null,
126
+ error: typeof o.error === "string" ? o.error : null,
127
+ raw: data,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Render the terminal aggregate status in house style — the honest queue/run state,
133
+ * NEVER a bare "merged". Pure given its inputs; returns the process exit code.
134
+ * @param {ReturnType<typeof normalizeIntegrateResponse>} result
135
+ * @param {{ tenant:string, label:string }} ctx
136
+ * @returns {number}
137
+ */
138
+ export function reportIntegrated(result, { tenant, label }) {
139
+ const state = `${result.queueState ?? "?"}/${result.runState ?? "—"}`;
140
+ if (result.ok) {
141
+ console.log(`\n ✓ queued ${label} into ${tenant}'s preview aggregate — it is GREEN.`);
142
+ console.log(` aggregate: ${state}${result.aggregateSha ? ` (${result.aggregateSha})` : ""}`);
143
+ if (result.pointerMoved) console.log(" the shared preview pointer moved to this aggregate.");
144
+ console.log(" → next: `tot ship` to promote this green aggregate live.");
145
+ return 0;
146
+ }
147
+ // Honest non-green: the candidate did NOT land in the shippable aggregate.
148
+ console.log(`\n ✗ ${label} did NOT integrate into ${tenant}'s preview aggregate.`);
149
+ console.log(` aggregate: ${state}${result.reason ? ` (${result.reason})` : ""}`);
150
+ if (result.statusMessage) console.log(` why: ${result.statusMessage}`);
151
+ console.log(" → next: fix the candidate (re-`tot preview`), then re-run `tot accept`.");
152
+ return 1;
153
+ }
154
+
155
+ /**
156
+ * The accept-means-integrate flow after args are parsed: state the exact plan,
157
+ * confirm, then POST `/api/changes/integrate` (operator-secret transport) and render
158
+ * the honest terminal aggregate status. `fetch`/`confirmPlan` injected so it is
159
+ * unit-tested with no network/TTY.
160
+ *
161
+ * @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]
164
+ * @returns {Promise<number>} process exit code
165
+ */
166
+ export async function runIntegrate(
167
+ { tenant, pr, changeId, headSha, secret, storefrontUrl = null, yes = false },
168
+ deps = {},
169
+ ) {
170
+ const fetchImpl = deps.fetch || globalThis.fetch;
171
+ const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
172
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
111
173
  const label = pr != null ? `PR #${pr}` : changeId;
112
- const merged = Boolean(status?.shipped || accept?.shipped);
113
- if (merged) {
114
- console.log(`\n ✓ merged ${label} into main for ${tenant}.`);
115
- console.log(
116
- " next: `tot ship` (or an operator `tot ship`/deploy) to promote main to preview + live.",
174
+
175
+ // 1. State the EXACT plan (shared U10 affordance) — queue-integrate-into-preview,
176
+ // NO merge, NO go-live and gate on an explicit confirm.
177
+ const planLines = planForAction({ action: "accept", tenant, pr, changeId, headSha });
178
+ const { confirmed, reason } = await confirmPlan(planLines, {
179
+ yes,
180
+ question: `Queue ${label} for integration into ${tenant}'s preview aggregate?`,
181
+ });
182
+ if (!confirmed) {
183
+ if (reason === "non-tty") {
184
+ console.error(
185
+ fail(
186
+ "refusing to integrate without confirmation on a non-TTY.",
187
+ "re-run with --yes (an explicit human affirmative), or from an interactive terminal.",
188
+ ),
189
+ );
190
+ return 2;
191
+ }
192
+ console.log("Aborted — nothing was integrated.");
193
+ return 1;
194
+ }
195
+
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
+ ),
117
204
  );
118
- return 0;
205
+ return 2;
119
206
  }
120
- // The accept committed but the terminal state hasn't been observed yet (the
121
- // pipeline may still be landing reconcile/promote/verify) — report honestly.
122
- console.log(`\n ~ accept committed for ${label} on ${tenant}; the merge is landing now.`);
123
- console.log(" → next: re-check shortly, then `tot ship` / deploy to go live.");
124
- return 0;
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 } : {}),
218
+ };
219
+
220
+ // 3. POST the honest accept path (b07 queue enqueue) and render the terminal state.
221
+ let res;
222
+ try {
223
+ res = await fetchImpl(`${base}/api/changes/integrate`, {
224
+ method: "POST",
225
+ headers: authHeaders,
226
+ body: JSON.stringify(requestBody),
227
+ });
228
+ } catch (e) {
229
+ console.error(
230
+ fail(`couldn't reach the integration queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
231
+ );
232
+ return 1;
233
+ }
234
+
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
+ // A pre-flight error (auth, unknown tenant, candidate not found) is an HTTP 4xx
244
+ // with `{ error }` and no queue verdict — surface it distinctly from a red run.
245
+ 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
+ ),
256
+ );
257
+ return 1;
258
+ }
259
+
260
+ return reportIntegrated(result, { tenant, label });
125
261
  }
126
262
 
127
263
  /**
@@ -148,100 +284,30 @@ export async function run(argv, ctx) {
148
284
  }
149
285
 
150
286
  const prRaw = args.pr;
151
- const pr = prRaw != null && `${prRaw}`.trim() && Number.isFinite(Number(prRaw)) ? Number(prRaw) : null;
152
- if (pr == null && !args.changeId) {
153
- console.error(
154
- fail("no PR or change to accept.", "pass --pr <N> (the PR number to merge) and --change-id <chg_...>."),
155
- );
156
- return 2;
157
- }
158
-
159
- const changeId = (args.changeId || "").trim();
160
- if (!changeId) {
161
- // u9: auto-resolve PR→changeId via forge PR-read (candidate_status) — until
162
- // then an operator targeting a specific PR must supply the change-record id
163
- // directly (there's no cross-tenant forge lookup this CLI can do for a PR
164
- // that isn't the caller's own checkout).
287
+ const pr =
288
+ prRaw != null && `${prRaw}`.trim() && Number.isFinite(Number(prRaw)) ? Number(prRaw) : null;
289
+ const changeId = (args.changeId || "").trim() || null;
290
+ if (pr == null && !changeId) {
165
291
  console.error(
166
292
  fail(
167
- `no --change-id for PR #${pr} can't auto-resolve it yet.`,
168
- "pass --change-id <chg_...> (the change record a prior review step opened/readied), " +
169
- "or run `tot ship` from the PR author's own checkout instead.",
293
+ "no PR or change to integrate.",
294
+ "pass --pr <N> (the PR number to queue), or --change-id <id> to target a specific record.",
170
295
  ),
171
296
  );
172
297
  return 2;
173
298
  }
174
299
 
175
300
  const headSha = (args.headSha || "").trim() || null;
301
+ const storefrontUrl =
302
+ args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
176
303
 
177
- // Self-declaring: state the EXACT plan before acting (the shared plan module,
178
- // unit U10) plus the accept-specific caveat that main doesn't go live/preview
179
- // until the next ship/deploy.
180
- const planLines = [
181
- ...planForAction({ action: "accept", tenant, pr, changeId, headSha }),
182
- " note: main becomes default-hosted on preview + live only after the next `tot ship` / deploy.",
183
- ];
184
- const { confirmed, reason } = await printPlanAndConfirm(planLines, {
304
+ return await runIntegrate({
305
+ tenant,
306
+ pr,
307
+ changeId,
308
+ headSha,
309
+ secret: resolveOperatorSecret(args.secret, env),
310
+ storefrontUrl,
185
311
  yes: args.yes,
186
- question: "Merge this into main?",
187
312
  });
188
- if (!confirmed) {
189
- if (reason === "non-tty") {
190
- console.error(
191
- fail(
192
- "refusing to merge without confirmation on a non-TTY.",
193
- "re-run with --yes (an explicit human affirmative), or from an interactive terminal.",
194
- ),
195
- );
196
- return 2;
197
- }
198
- console.log("Aborted — nothing was merged.");
199
- return 1;
200
- }
201
-
202
- const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
203
- const client = createMcpClient(baseUrl);
204
- try {
205
- await establishSession(client, { env, prefer: args.identity || undefined });
206
- // Bind the active tenant so change_accept/change_status read the right scope.
207
- await client.callTool("client_switch", { tenant });
208
-
209
- let accept;
210
- try {
211
- accept = normalizeChangeResult(
212
- await client.callTool("change_accept", {
213
- id: changeId,
214
- tenant,
215
- dryRun: false,
216
- ...(headSha ? { expectedHeadSha: headSha } : {}),
217
- // Stable per (changeId, head): a re-run after a blip returns the
218
- // original accept instead of double-merging.
219
- idempotencyKey: `accept-${changeId}-${headSha || pr || "pr"}`,
220
- }),
221
- );
222
- } catch (e) {
223
- console.error(
224
- fail(
225
- `the accept pipeline refused to merge this change: ${String(e?.message || e)}`,
226
- "check its review/reconcile status, then re-run `tot accept`",
227
- ),
228
- );
229
- return 1;
230
- }
231
-
232
- const status = await pollChangeShipped(client, { id: changeId, tenant });
233
- return reportAccepted(accept, status, { tenant, pr, changeId });
234
- } catch (e) {
235
- if (e instanceof AuthUnavailableError) {
236
- console.error(fail("sign in to accept a change", e.hint || "run `tot login`, then re-run `tot accept`"));
237
- return 1;
238
- }
239
- console.error(
240
- fail(
241
- `couldn't reach the accept service: ${String(e?.message || e)}`,
242
- "check your connection and that you're signed in, then re-run",
243
- ),
244
- );
245
- return 1;
246
- }
247
313
  }