@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.
@@ -1,298 +1,158 @@
1
1
  /**
2
- * `tot ship` — deploy main preview + live (decisions operator-verb-and-hosting-model
3
- * + ship-context-dependent-semantics). The deliberate ship gate.
4
- *
5
- * The second half of the dev → preview → ship loop:
2
+ * `tot ship` — publish the tenant's CURRENT GREEN AGGREGATE to live. ONE
3
+ * meaning (unit b10 — SUPERSEDES the retired context-dependent ship, decision
4
+ * `ship-context-dependent-semantics`, and its developer-accept-then-deploy /
5
+ * operator-deploy-only / ship-any-built-PR branches):
6
6
  *
7
7
  * tot dev run your store locally with save→reload
8
8
  * tot preview push it to a reviewable preview (validate → reconcile → compliance)
9
- * tot ship accept + deploy (developer) / deploy (operator) ← you are here
10
- *
11
- * SHIP IS CONTEXT-DEPENDENT (decision ship-context-dependent-semantics). It NEVER
12
- * silently guesses — it detects the context (`detectShipContext`), prints the EXACT
13
- * plan for THAT context via the SHARED affordance (`../plan.mjs`, unit U10 — the same
14
- * `planForAction` + `printPlanAndConfirm` every mutating operator verb and the `/admin`
15
- * confirm dialog use), and confirms before acting:
16
- *
17
- * • DEVELOPER — run in your OWN store checkout on a branch: ship = ACCEPT (merge this
18
- * branch/PR into main) AND THEN DEPLOY (main → preview + live). This is `runShip`.
19
- * • OPERATOR — target a `--pr N --tenant t` that ISN'T your checkout: ship = DEPLOY
20
- * only; the merge is the separate `tot accept` verb. This is `runShipOperator`.
21
- *
22
- * The DEVELOPER contract, deliberately strict because this changes the LIVE site:
9
+ * tot ship publish the current green aggregate live ← you are here
23
10
  *
24
- * 1. Resolve the ACTIVE candidate for this checkout (the SAME handle `tot preview`
25
- * last pushed to see `resolveActiveChangeId`, the seam u4 rekeys to be
26
- * branch-bound) and REQUIRE it to be OPEN and RECONCILED. A merged/closed
27
- * candidate, or one whose evidence isn't green yet, is refused with a clear
28
- * next step never shipped.
29
- * 2. ALWAYS render a diff-vs-live (what this ship changes on the live site), then
30
- * print the EXACT plan and require ONE explicit confirm, defaulting to NO. `--yes`
31
- * confirms non-interactively (the operator's explicit go); a NON-TTY WITHOUT
32
- * `--yes` REFUSES rather than auto-confirm — nothing ships without an explicit yes.
33
- * Merge-to-main / go-live / deploy stay HUMAN GATES.
34
- * 3. On confirm, `change_accept` (the human ship gate) transitions the change to
35
- * shipped; we then poll `change_status` until it reports shipped and print the
36
- * live URL.
11
+ * `tot ship` NEVER takes a PR/candidate target and NEVER infers context from
12
+ * your checkout or who's running it. There is nothing to detect: it ships the
13
+ * tenant's shared `preview` aggregate the batch of PRs that integrated
14
+ * cleanly and went green (b07's queue) full stop. The candidate-level merge
15
+ * is the separate `tot accept` verb (`accept.mjs`); ship never merges.
37
16
  *
38
- * UNAUTHORISED actors don't hit a wall: if you can't approve the ship yourself we
39
- * RECORD the approval request (`change_ready` queues it for review) and print WHO
40
- * can approve it, so the change moves forward instead of dead-ending.
17
+ * THE FLOW:
41
18
  *
42
- * WIRE (verified server-side by u7, decision ship-path-wire-resolutions):
43
- * - A CANDIDATE and a change RECORD are DISTINCT namespaces. The candidate handle
44
- * (`changeId`, the `candidate/<changeId>` Gitea PR) is NOT the change-record id.
45
- * `change_request_review` / `change_ready` / `change_accept` resolve the record
46
- * via `getChange(tenant, id)`, where `id` is the `chg_<uuid>` minted by
47
- * `change_open` a different id entirely.
48
- * - So ship RESOLVES a real change-record id before the gate: `resolveChangeRecordId`
49
- * opens a record for the exact reviewed commit (`change_open commit:<HEAD>`, so
50
- * u7's promote-by-digest can content-address the reviewed tree — without `commit:`
51
- * the accept fails closed `no_reviewed_commit`) and readies it against the
52
- * authoritative reconcile evidence keyed by (tenant, commit). If that commit hasn't
53
- * reconciled (no evidence), `change_ready` fails and we refuse with a clear next step.
54
- * - `change_request_review` then reads the readied record (evidence green? authorised
55
- * to promote?); `change_accept` is the commit. All key on the resolved `chg_` id.
19
+ * 1. GET the read-only PLAN from `/api/changes/ship` — b09's
20
+ * `AggregateShipOrchestrator.plan()` exposed over HTTP: the pinned
21
+ * aggregate sha, its content-addressed artifact digest, every included
22
+ * PR, the rollback target (what a rollback would restore), and the
23
+ * go-live paywall verdict. Zero side effects.
24
+ * 2. Print the EXACT plan via the shared U10 affordance (`../plan.mjs`) and
25
+ * require ONE explicit confirm, defaulting to NO. `--yes` confirms
26
+ * non-interactively; a non-TTY WITHOUT `--yes` REFUSES nothing ships
27
+ * without an explicit yes.
28
+ * 3. On confirm, POST `/api/changes/ship` with `confirmGoLive: true` (the
29
+ * human go-live gate) and the reviewed `expectedAggregateSha` +
30
+ * `expectedArtifactDigest` so a STALE plan (the aggregate moved, or the
31
+ * artifact changed, between GET and POST) is refused rather than shipping
32
+ * something nobody reviewed.
33
+ * 4. Report the orchestrator's HONEST terminal state verbatim:
34
+ * `shipped` (the live channel is VERIFIED serving the pinned artifact —
35
+ * the only state ever rendered as "shipped live"), `refused` (a gate
36
+ * refused before any side effect — not green, paywalled, etc.),
37
+ * `promote_failed` / `record_failed` (a partial failure, recoverable by
38
+ * re-running `tot ship`, never a false live claim).
56
39
  *
57
- * Dependency-free (global fetch via the MCP client + `git`); pure helpers are
58
- * exported and unit-tested with a mock client, no network, no TTY.
40
+ * TRANSPORT: the CLI holds no storefront cookie (its OAuth login is with the
41
+ * MCP, a different trust boundary), so `/api/changes/ship` is reached with a
42
+ * Bearer OPERATOR SECRET + `X-Tot-Owner` — the SAME transport the retired
43
+ * `tot ship <N> --tenant` operator-by-PR path used. No MCP client, no git: ship
44
+ * is now pure HTTP (dependency-free — global `fetch` only).
59
45
  */
60
- import { execFileSync } from "node:child_process";
61
- import { createMcpClient } from "../mcp.mjs";
62
- import { establishSession, AuthUnavailableError } from "../auth.mjs";
63
46
  import { fail } from "../errors.mjs";
64
47
  import { planForAction, printPlanAndConfirm } from "../plan.mjs";
65
48
  import { startProgress } from "../progress.mjs";
66
49
  import { openBrowser } from "../open.mjs";
67
- import {
68
- repoNameFromRemote,
69
- parseNameStatus,
70
- deriveChangeId,
71
- actorKeyFor,
72
- currentBranch,
73
- buildChangeSummary,
74
- } from "./submit.mjs";
75
- import {
76
- defaultCandidateStatePath,
77
- readActiveChangeId,
78
- isTerminalCandidateState,
79
- } from "../candidate-state.mjs";
80
50
 
81
- const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
82
51
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
83
52
 
84
- const USAGE = `tot ship — deploy main preview + live (context-dependent)
85
-
86
- tot ship ship the preview you last pushed with \`tot preview\`
87
- tot ship <N> DEVELOPER: ship PR #N from your OWN checkout
88
- tot ship pr/<N> (same — also accepts \`pr#N\`, \`#N\`)
89
- tot ship --pr <N> --tenant <t> OPERATOR: ship ANY built PR (not just your checkout)
90
- tot ship --change-id <chg_…> target an explicit change record (operator targeting)
91
- tot ship --secret <s> operator secret (prefer the env vars below)
92
- tot ship --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
93
- tot ship --yes confirm non-interactively (skip the [y/N] prompt)
94
- tot ship --identity <id> sign in as a specific identity for this ship
95
- tot ship --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
96
-
97
- \`tot ship\` is CONTEXT-DEPENDENT (decision ship-context-dependent-semantics):
53
+ const USAGE = `tot ship — publish the tenant's CURRENT GREEN AGGREGATE to live (ONE meaning)
98
54
 
99
- DEVELOPER run in your OWN store checkout on a branch: ship = ACCEPT (merge
100
- this branch/PR into main) AND THEN DEPLOY (main preview + live). A POSITIONAL
101
- \`tot ship 4\` / \`tot ship pr/4\` names the PR to ship — same developer semantics,
102
- the tenant is inferred from the checkout.
103
- OPERATOR target a PR that ISN'T your checkout (\`tot ship 4\` with no local
104
- candidate, or an explicit \`--pr N --tenant t\`): ship resolves PR N the built
105
- candidate from the SHARED queue (GET /api/changes) and ships it via the atomic
106
- accept (POST /api/changes/accept). This needs an OPERATOR SECRET (env below).
107
- If PR N has no built candidate, build it first with \`tot preview build\`.
55
+ tot ship ship the checkout tenant's current green aggregate
56
+ tot ship --tenant <t> ship a named tenant's current green aggregate (no checkout needed)
57
+ tot ship --yes confirm non-interactively (skip the [y/N] prompt)
58
+ tot ship --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
59
+ tot ship --secret <s> operator secret (prefer the env vars below)
60
+ tot ship --no-open don't open the live URL in your browser
108
61
 
109
62
  Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
110
63
  GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).
111
64
 
112
- It NEVER silently guesses: it prints the EXACT plan for THAT context and confirms
113
- first (\`--yes\` to confirm non-interactively; a non-TTY without \`--yes\` refuses).
114
- Merge-to-main / go-live / deploy stay HUMAN GATES. If you can't approve the ship
115
- yourself, it records the request and tells you who can.`;
65
+ \`tot ship\` has ONE meaning: it publishes the tenant's shared preview
66
+ aggregate the batch of PRs that integrated cleanly and went GREEN — to
67
+ live. It NEVER takes a PR/candidate target and never infers anything from
68
+ your checkout or who you are. It shows you the EXACT plan first (the pinned
69
+ sha, the artifact digest, every included PR, the rollback target, and the
70
+ go-live paywall verdict) and waits for one explicit confirm (\`--yes\` to
71
+ skip the prompt; a non-TTY without \`--yes\` refuses). Ship then reports
72
+ VERIFIED live truth — never a false "shipped" — surfacing a refusal or a
73
+ recoverable partial failure honestly if it isn't.
116
74
 
117
- /**
118
- * Parse a POSITIONAL PR token — `4`, `pr/4`, `pr#4`, `pr-4`, `#4`, `PR/4` — to the
119
- * PR number as a string, or null when the token isn't a PR reference. Pure. Used so
120
- * `tot ship 4` / `tot ship pr/4` name a PR positionally, the same value the `--pr`
121
- * flag carries (but developer-context — see `detectShipContext`).
122
- * @param {string} token
123
- * @returns {string|null}
124
- */
125
- export function parsePrToken(token) {
126
- if (typeof token !== "string") return null;
127
- const m = token.trim().match(/^(?:pr)?[/#-]?(\d+)$/i);
128
- return m ? m[1] : null;
129
- }
75
+ The candidate-level merge is the separate \`tot accept\` / \`tot merge\` verb —
76
+ \`tot ship\` never merges.`;
130
77
 
131
78
  /** Parse `tot ship` argv. Pure. */
132
79
  export function parseShipArgs(argv) {
133
80
  const a = {
134
- mcp: null,
135
- identity: null,
136
- noOpen: false,
137
- yes: false,
138
- pr: null,
139
- // True when `pr` came from a POSITIONAL token (`tot ship 4`) rather than the
140
- // `--pr` flag. A positional PR in your OWN checkout is DEVELOPER intent (ship my
141
- // own PR — accept+deploy); the `--pr` flag stays OPERATOR targeting (deploy-only).
142
- prPositional: false,
143
81
  tenant: null,
144
- changeId: null,
145
- headSha: null,
82
+ yes: false,
83
+ noOpen: false,
146
84
  url: null,
147
85
  secret: null,
148
86
  help: false,
149
87
  };
150
88
  for (let i = 0; i < argv.length; i++) {
151
89
  const t = argv[i];
152
- if (t === "--mcp") a.mcp = argv[++i];
153
- else if (t === "--identity") a.identity = argv[++i];
154
- else if (t === "--no-open") a.noOpen = true;
90
+ if (t === "--tenant") a.tenant = argv[++i];
155
91
  else if (t === "--yes" || t === "-y") a.yes = true;
156
- else if (t === "--pr") a.pr = argv[++i];
157
- else if (t === "--tenant") a.tenant = argv[++i];
158
- else if (t === "--change-id") a.changeId = argv[++i];
159
- else if (t === "--head-sha" || t === "--head") a.headSha = argv[++i];
92
+ else if (t === "--no-open") a.noOpen = true;
160
93
  else if (t === "--url") a.url = argv[++i];
161
94
  else if (t === "--secret") a.secret = argv[++i];
162
95
  else if (t === "--help" || t === "-h") a.help = true;
163
- else if (!t.startsWith("-") && a.pr == null) {
164
- // A POSITIONAL PR reference (`tot ship 4` / `tot ship pr/4` / `tot ship #4`).
165
- // Only the first one wins; an explicit `--pr` flag (set above) takes precedence.
166
- const n = parsePrToken(t);
167
- if (n != null) {
168
- a.pr = n;
169
- a.prPositional = true;
170
- }
171
- }
172
96
  }
173
97
  return a;
174
98
  }
175
99
 
176
- /**
177
- * Decide ship's CONTEXT (decision ship-context-dependent-semantics). Pure — no I/O.
178
- *
179
- * - "developer": run inside your OWN store checkout with no cross-target flags →
180
- * ship = ACCEPT (merge this branch/PR into main) AND THEN DEPLOY (main → preview
181
- * + live). This is the happy-path `tot ship` with no flags.
182
- * - "operator": you're targeting a specific `--pr`/`--tenant` that ISN'T your active
183
- * checkout → ship = DEPLOY only (the merge is the separate `tot accept` verb).
184
- *
185
- * Ship NEVER silently guesses which it is — this classifies from the EXPLICIT signals
186
- * (an explicit `--pr`, a `--tenant` that differs from the checkout, or a `--tenant`
187
- * named from outside any checkout), and the caller then prints the concrete plan for
188
- * THAT context and confirms.
189
- *
190
- * A POSITIONAL PR (`tot ship 4`) is the exception the `--pr` FLAG is not: it names the
191
- * developer's OWN PR to ship from their OWN checkout, so — absent any cross-target
192
- * signal (a differing `--tenant`, or being named from outside a checkout) — it stays
193
- * DEVELOPER (accept+deploy). The `--pr` flag remains OPERATOR targeting (deploy-only),
194
- * per its documented "a PR that isn't your checkout" contract.
195
- *
196
- * @param {{ pr?: number|string|null, prPositional?: boolean, tenant?: string|null,
197
- * ctxTenant?: string|null, ctxMode?: string|null }} p
198
- * @returns {"developer"|"operator"}
199
- */
200
- export function detectShipContext({ pr = null, prPositional = false, tenant = null, ctxTenant = null, ctxMode = null } = {}) {
201
- const hasPr = pr != null && `${pr}`.trim() !== "";
202
- const t = (tenant || "").trim();
203
- const ct = (ctxTenant || "").trim();
204
- const tenantDiffers = Boolean(t && ct && t !== ct);
205
- const targetingFromOutside = Boolean(t && ctxMode !== "checkout");
206
- // A positional `tot ship 4` inside your OWN checkout, with no cross-target flags, is
207
- // the developer shipping their own PR — accept+deploy, NOT operator deploy-only.
208
- const positionalOwnPr = hasPr && prPositional && ctxMode === "checkout" && !tenantDiffers && !targetingFromOutside;
209
- if (positionalOwnPr) return "developer";
210
- if (hasPr || tenantDiffers || targetingFromOutside) return "operator";
211
- return "developer";
212
- }
100
+ // ─── Operator-secret transport (shared with `tot pr list --tenant` / u16/u17) ────
101
+ //
102
+ // `resolveOperatorSecret` / `normalizeChangesQueue` are the SAME helpers the
103
+ // retired operator-by-PR ship path minted; `tot pr list --tenant` (`pr.mjs`)
104
+ // still imports them for its own (unrelated) `GET /api/changes` candidate-queue
105
+ // listing, so they stay here rather than move one wire, one place it's typed.
213
106
 
214
- // ─── Response normalisation (defensive — one MCP, but shapes may vary) ───────────
107
+ const OPERATOR_SECRET_ENV = ["PREVIEW_RECONCILE_SECRET", "GRANTS_ADMIN_SECRET", "TOT_OPERATOR_SECRET"];
215
108
 
216
109
  /**
217
- * Normalise a `candidate_status` result (a single ReviewEnvironment forge slice)
218
- * to the fields ship gates on. Returns null when there's no candidate to act on.
219
- * Pure unit-tested.
220
- * @param {any} r
221
- * @returns {{changeId?:string, state:string|null, headSha:string|null, baseSha:string|null,
222
- * prNumber:number|null, url:string|null, mergeable:unknown}|null}
110
+ * Resolve the operator secret from an explicit `--secret` or the env (first
111
+ * found). Pure given its env argument.
112
+ * @param {string|null|undefined} explicit
113
+ * @param {NodeJS.ProcessEnv} env
114
+ * @returns {string}
223
115
  */
224
- export function normalizeCandidate(r) {
225
- // `tot pr` already learned candidate_status can answer as an object, a
226
- // `{candidates:[…]}` list, or a single record — reuse that tolerance here.
227
- const c = Array.isArray(r)
228
- ? r[0]
229
- : r && Array.isArray(r.candidates)
230
- ? r.candidates[0]
231
- : r && Array.isArray(r.environments)
232
- ? r.environments[0]
233
- : r;
234
- if (!c || typeof c !== "object" || typeof c.changeId !== "string") return null;
235
- return {
236
- changeId: c.changeId,
237
- state: typeof c.state === "string" ? c.state : null,
238
- headSha: c.headSha ?? null,
239
- baseSha: c.baseSha ?? null,
240
- prNumber: typeof c.prNumber === "number" ? c.prNumber : null,
241
- url: c.url ?? null,
242
- mergeable: c.mergeable,
243
- };
116
+ export function resolveOperatorSecret(explicit, env = {}) {
117
+ if (explicit && `${explicit}`.trim()) return `${explicit}`.trim();
118
+ for (const k of OPERATOR_SECRET_ENV) {
119
+ const v = env[k];
120
+ if (v && `${v}`.trim()) return `${v}`.trim();
121
+ }
122
+ return "";
244
123
  }
245
124
 
246
125
  /**
247
- * Normalise a `change_request_review` result to the precondition signals ship
248
- * needs: is the evidence green (reconciled + promotable), is THIS session allowed
249
- * to promote, and for the unauthorised path — who can. Field names are read
250
- * defensively (the CLI is the first caller of this tool) so a plausible rename
251
- * degrades gracefully rather than crashing the gate. `reconciled`/`authorized` are
252
- * TRI-STATE-ish: they're only `true` when the server clearly says so, so an
253
- * unrecognised shape fails CLOSED (we don't ship on ambiguity). Pure — unit-tested.
254
- * @param {any} r
255
- * @returns {{reconciled:boolean, authorized:boolean, approvers:string[],
256
- * previewUrl:string|null, detail:string|null, raw:any}}
126
+ * Normalise a `GET /api/changes` body to the OPEN candidate list. Kept for
127
+ * `tot pr list --tenant` (`pr.mjs`) NOT used by ship itself anymore (ship
128
+ * reads the aggregate plan from `/api/changes/ship`, a distinct endpoint).
129
+ * @param {any} data
130
+ * @returns {Array<{changeId:string, prNumber?:number|null, headSha?:string|null, previewUrl?:string|null}>}
257
131
  */
258
- export function normalizeReview(r) {
259
- const o = (r && typeof r === "object" ? r : {});
260
- const ev = o.evidence && typeof o.evidence === "object" ? o.evidence : {};
261
- // Evidence green ⇒ reconciled + promotable. Accept the several plausible flags.
262
- const reconciled = firstBool([
263
- o.evidenceGreen, o.green, o.promotable, ev.green, ev.ok, ev.promotable,
264
- o.verdict === "green" || o.verdict === "pass" ? true : undefined,
265
- ]);
266
- const authorized = firstBool([
267
- o.authorized, o.authorised, o.canPromote, o.canApprove, o.authorisedToPromote,
268
- ]);
269
- const approvers = normalizeApprovers(
270
- o.approvers ?? o.eligibleApprovers ?? o.whoCanApprove ?? o.promoters ?? [],
132
+ export function normalizeChangesQueue(data) {
133
+ const list = Array.isArray(data)
134
+ ? data
135
+ : data && Array.isArray(data.changes)
136
+ ? data.changes
137
+ : [];
138
+ // Keep an entry if it has a changeId (a BUILT candidate) OR a numeric prNumber (a
139
+ // not-built forge PR merged in by GET /api/changes, u9b) — so `tot pr list --tenant`
140
+ // shows orphaned/un-built PRs too, not only built candidates. (Re-applied after a
141
+ // branch-scale ship rework reverted it to changeId-only.)
142
+ return list.filter(
143
+ (c) =>
144
+ c &&
145
+ typeof c === "object" &&
146
+ (typeof c.changeId === "string" || typeof c.prNumber === "number"),
271
147
  );
272
- return {
273
- reconciled: reconciled === true,
274
- authorized: authorized === true,
275
- approvers,
276
- previewUrl: o.previewUrl ?? ev.previewUrl ?? null,
277
- detail: typeof o.detail === "string" ? o.detail : (typeof o.reason === "string" ? o.reason : null),
278
- raw: r,
279
- };
280
- }
281
-
282
- /** First non-undefined boolean in a list, else undefined. Pure. */
283
- function firstBool(candidates) {
284
- for (const v of candidates) if (typeof v === "boolean") return v;
285
- return undefined;
286
148
  }
287
149
 
288
- /** Coerce an approvers payload (strings, or objects with email/name) to a label list. Pure. */
289
- export function normalizeApprovers(list) {
290
- if (!Array.isArray(list)) return [];
291
- return list
292
- .map((a) => (typeof a === "string" ? a : a && (a.email || a.name || a.label || a.id)))
293
- .filter((s) => typeof s === "string" && s.trim())
294
- .map((s) => s.trim());
295
- }
150
+ // ─── Shared with `tot accept` (`accept.mjs`) ─────────────────────────────────────
151
+ //
152
+ // `tot accept` queues a PR's integration into the protected `preview` aggregate
153
+ // (NOT a merge to main — see accept.mjs's header) — a DISTINCT operator verb from
154
+ // ship (no deploy). It imports these two normalisers straight from here rather
155
+ // than re-implementing them.
296
156
 
297
157
  /**
298
158
  * Normalise a `change_accept` / `change_status` result to { shipped, state,
@@ -314,219 +174,9 @@ export function normalizeChangeResult(r) {
314
174
  };
315
175
  }
316
176
 
317
- // ─── The candidate-resolution seam (u4 extends THIS) ─────────────────────────────
318
-
319
- /**
320
- * Resolve the ACTIVE candidate's changeId for this checkout — the SAME resolution
321
- * `tot preview` uses so ship promotes exactly what preview last pushed: the
322
- * remembered active pointer (a prior `--new` / roll), else the STABLE
323
- * per-developer-per-tenant default (`deriveChangeId`).
324
- *
325
- * ┌─ SEAM (u4) ────────────────────────────────────────────────────────────────┐
326
- * │ This is the ONE place ship keys the candidate on (tenant, actor, branch). │
327
- * │ u4 (branch-bound candidates) keys HERE — folding in the current git branch — │
328
- * │ so `tot preview` and `tot ship` resolve the SAME branch-bound candidate, │
329
- * │ WITHOUT touching the gate, diff, confirm, or accept flow below. On the │
330
- * │ default branch `branch` is null and this is byte-identical to pre-u4. │
331
- * └──────────────────────────────────────────────────────────────────────────────┘
332
- * Pure given its inputs (state is read through candidate-state.mjs). Exported so a
333
- * follow-on unit can wrap/replace it.
334
- * @param {{ statePath:string, mcpUrl:string, repo:string, tenant:string, actorKey:string,
335
- * branch?:string|null }} keys
336
- * @returns {string}
337
- */
338
- export function resolveActiveChangeId({ statePath, mcpUrl, repo, tenant, actorKey, branch = null }) {
339
- const stable = deriveChangeId(tenant, actorKey, branch);
340
- const active = readActiveChangeId(statePath, { mcpUrl, repo, branch });
341
- return active || stable;
342
- }
343
-
344
- /**
345
- * Normalise a `change_open` result to its minted change-record id. `change_open`
346
- * returns the record id flat in `data` (id, state); read it defensively. Pure.
347
- * @param {any} r
348
- * @returns {{ id: string|null, state: string|null }}
349
- */
350
- export function normalizeOpenedChange(r) {
351
- const o = r && typeof r === "object" ? r : {};
352
- return {
353
- id: typeof o.id === "string" && o.id ? o.id : null,
354
- state: typeof o.state === "string" ? o.state : null,
355
- };
356
- }
357
-
358
- /**
359
- * Resolve the change-RECORD id (`chg_<uuid>`) that the ship gate + `change_accept`
360
- * key on — a DISTINCT namespace from the candidate handle (see the module header).
361
- * There is NO candidate→record lookup on the wire (`change_status` lists only
362
- * id/title/state, never the head sha), so we CAPTURE the id at `change_open`, the
363
- * option u7's decision (ship-path-wire-resolutions) records as supported:
364
- *
365
- * 1. read the reviewed commit — the local HEAD, the commit `tot preview` pushed and
366
- * the reconcile evidence is keyed on;
367
- * 2. `change_open` it with `commit:` set, so u7's promote-by-digest can content-
368
- * address the exact reviewed tree (absent it, `change_accept` fails closed
369
- * `no_reviewed_commit`);
370
- * 3. `change_ready` it, attaching the AUTHORITATIVE reconcile evidence keyed by
371
- * (tenant, commit). This REQUIRES that commit to have reconciled — if it hasn't,
372
- * `change_ready` throws (no authoritative evidence) and we propagate it so the
373
- * caller refuses with a "run `tot preview` first" next step (never ship a commit
374
- * whose evidence isn't in).
375
- *
376
- * Returns the resolved `chg_` id. Throws if the record can't be opened/readied — the
377
- * caller fails CLOSED (never falls back to shipping the candidate handle or no record).
378
- * The `change_*` calls go through the same client; `git` is injected.
379
- * @param {{callTool:Function}} client
380
- * @param {{ tenant:string, git:(args:string[])=>string,
381
- * changeSummary?:{ title?:string, body?:string[] } }} params
382
- * @returns {Promise<string>}
383
- */
384
- export async function resolveChangeRecordId(client, { tenant, git, changeSummary }) {
385
- const commit = git(["rev-parse", "HEAD"]).trim();
386
- if (!commit) {
387
- throw new Error("couldn't read the reviewed commit (git HEAD) for this checkout");
388
- }
389
- const title = (changeSummary?.title || `Ship ${tenant} store change`).slice(0, 200);
390
- const body = changeSummary?.body?.length ? changeSummary.body.join("\n").slice(0, 2000) : undefined;
391
- const opened = normalizeOpenedChange(
392
- await client.callTool("change_open", {
393
- title,
394
- ...(body ? { summary: body } : {}),
395
- commit,
396
- tenant,
397
- dryRun: false,
398
- }),
399
- );
400
- if (!opened.id) {
401
- throw new Error("change_open did not return a change-record id");
402
- }
403
- // Ready it against the authoritative reconcile evidence for THIS exact commit —
404
- // throws (no_authoritative_evidence) if the commit hasn't reconciled yet.
405
- await client.callTool("change_ready", { id: opened.id, tenant, dryRun: false });
406
- return opened.id;
407
- }
408
-
409
- /** A neutral review used only when there's no open candidate to resolve a record for
410
- * — shipReadiness reports the candidate blocker first and never reads these. */
411
- export const NO_REVIEW = Object.freeze({
412
- reconciled: false, authorized: false, approvers: [], previewUrl: null, detail: null, raw: null,
413
- });
414
-
415
- // ─── The ship gate (pure) ────────────────────────────────────────────────────────
416
-
417
- /**
418
- * Decide, from the resolved candidate + the review verdict, whether the ship may
419
- * proceed — the correctness-critical core, kept PURE so every branch is unit-tested
420
- * without a client/TTY/git. Order matters: OPEN, then RECONCILED, then AUTHORISED,
421
- * so the developer always sees the most fundamental blocker first.
422
- *
423
- * @param {ReturnType<typeof normalizeCandidate>} candidate
424
- * @param {ReturnType<typeof normalizeReview>} review
425
- * @returns {{ kind: "no-candidate"|"not-open"|"not-reconciled"|"unauthorized"|"ready",
426
- * what?: string, next?: string }}
427
- */
428
- export function shipReadiness(candidate, review) {
429
- if (!candidate) {
430
- return {
431
- kind: "no-candidate",
432
- what: "no active candidate to ship for this checkout",
433
- next: "push one first with `tot preview`, then `tot ship`",
434
- };
435
- }
436
- if (candidate.state !== "open") {
437
- // Terminal (merged/closed) vs simply-not-open both mean "nothing OPEN to ship".
438
- const terminal = isTerminalCandidateState(candidate.state);
439
- return {
440
- kind: "not-open",
441
- what: terminal
442
- ? `this candidate is already ${candidate.state} — there's nothing open to ship`
443
- : `this candidate isn't open (state: ${candidate.state ?? "unknown"})`,
444
- next: "`tot preview` to push a fresh candidate, then `tot ship`",
445
- };
446
- }
447
- if (!review.reconciled) {
448
- return {
449
- kind: "not-reconciled",
450
- what: `this preview hasn't reconciled cleanly yet${review.detail ? ` — ${review.detail}` : ""}`,
451
- next: "run `tot preview` and wait for the green reconcile/compliance result, then `tot ship`",
452
- };
453
- }
454
- if (!review.authorized) {
455
- return { kind: "unauthorized" };
456
- }
457
- return { kind: "ready" };
458
- }
459
-
460
- // ─── Diff-vs-live (pure render + thin git compute) ───────────────────────────────
461
-
462
- /**
463
- * Render the "what goes live" block from parsed name-status entries + a shortstat
464
- * line. Always prints SOMETHING actionable: the changed paths when we have them,
465
- * else a pointer to the candidate PR so the reviewer can read the full diff. Pure —
466
- * unit-tested.
467
- * @param {{ entries:{status:string,path:string,from?:string}[], statLine?:string,
468
- * prUrl?:string|null, ok:boolean }} input
469
- * @returns {string[]}
470
- */
471
- export function renderDiffVsLive({ entries, statLine, prUrl, ok }) {
472
- const lines = ["", " This ship will change the LIVE site:"];
473
- if (ok && entries.length) {
474
- for (const e of entries.slice(0, 40)) {
475
- const label = e.status === "R" && e.from ? `${e.from} → ${e.path}` : e.path;
476
- lines.push(` ${statusGlyph(e.status)} ${label}`);
477
- }
478
- if (entries.length > 40) lines.push(` … +${entries.length - 40} more file(s)`);
479
- if (statLine) lines.push(` (${statLine})`);
480
- } else {
481
- lines.push(" (couldn't render a local file diff for this candidate)");
482
- if (prUrl) lines.push(` review the full diff in the candidate PR: ${prUrl}`);
483
- }
484
- return lines;
485
- }
486
-
487
- /** A/M/D/R → a stable one-char glyph for the diff list. Pure. */
488
- function statusGlyph(status) {
489
- return { A: "+", M: "~", D: "-", R: "»", C: "»" }[status] || "·";
490
- }
491
-
492
- /**
493
- * Compute the diff-vs-live (base→head) via git in the checkout — best-effort. Tries
494
- * the local objects first; if the shas aren't present it fetches once and retries;
495
- * if it still can't, returns `ok:false` so the caller falls back to the PR pointer.
496
- * `git` is injected (a `(args:string[])=>string` runner) so it's testable.
497
- * @param {(args:string[]) => string} git
498
- * @param {string|null} baseSha
499
- * @param {string|null} headSha
500
- * @returns {{ entries:{status:string,path:string,from?:string}[], statLine:string, ok:boolean }}
501
- */
502
- export function computeDiffVsLive(git, baseSha, headSha) {
503
- if (!baseSha || !headSha) return { entries: [], statLine: "", ok: false };
504
- const range = `${baseSha}..${headSha}`;
505
- const tryDiff = () => ({
506
- entries: parseNameStatus(git(["diff", "--name-status", range])),
507
- statLine: git(["diff", "--shortstat", range]).trim(),
508
- });
509
- try {
510
- return { ...tryDiff(), ok: true };
511
- } catch {
512
- // The shas may not be local yet (base moved, head only on the remote) — one
513
- // fetch, then retry. Still failing ⇒ fall back to the PR pointer.
514
- try {
515
- git(["fetch", "--quiet", "origin"]);
516
- return { ...tryDiff(), ok: true };
517
- } catch {
518
- return { entries: [], statLine: "", ok: false };
519
- }
520
- }
521
- }
522
-
523
- // ─── Ship-decision poll ──────────────────────────────────────────────────────────
524
-
525
177
  /**
526
178
  * Poll `change_status` until the change reports shipped (or the attempts budget
527
- * runs out). `change_accept` transitions synchronously, so this usually resolves on
528
- * the first read — it exists to CONFIRM the shipped state and pick up the live URL,
529
- * and to tolerate a promote that lands a beat later. Injectable delay/attempts.
179
+ * runs out). Injectable delay/attempts.
530
180
  * @param {{callTool:Function}} client
531
181
  * @param {{ id:string, tenant?:string }} target
532
182
  * @param {{ attempts?:number, delayMs?:number, sleep?:(ms:number)=>Promise<void> }} [opts]
@@ -542,213 +192,7 @@ export async function pollChangeShipped(client, { id, tenant }, { attempts = 6,
542
192
  return last;
543
193
  }
544
194
 
545
- // ─── Orchestration ───────────────────────────────────────────────────────────────
546
-
547
- /**
548
- * The ship flow after a session is established — resolve the candidate, gate,
549
- * diff-vs-live, confirm, accept, poll, report. Split out from `run` so it's driven
550
- * in tests with a mock client + injected git/confirm/interactive, no network/TTY.
551
- *
552
- * @param {{callTool:Function}} client an MCP client (real or mock)
553
- * @param {{ tenant:string, changeId:string, repo:string,
554
- * git:(args:string[])=>string, noOpen?:boolean, yes?:boolean, storefrontUrl?:string|null,
555
- * changeSummary?:{ title?:string, body?:string[] }, expectedPr?:number|string|null }} params
556
- * @param {{ confirmPlan?:typeof printPlanAndConfirm,
557
- * poll?:typeof pollChangeShipped, openUrl?:(u:string)=>boolean, progress?:boolean,
558
- * resolveRecord?:typeof resolveChangeRecordId,
559
- * shipByPrFallback?:()=>Promise<number> }} [deps]
560
- * shipByPrFallback (u16) — when a POSITIONAL `tot ship <N>` finds NO candidate for
561
- * this checkout, invoke this instead of the "no active candidate" dead-end: it ships
562
- * the built PR from the shared queue (the operator-by-PR path).
563
- * @returns {Promise<number>} process exit code
564
- */
565
- export async function runShip(client, { tenant, changeId, repo, git, noOpen, yes = false, storefrontUrl = null, changeSummary, expectedPr = null }, deps = {}) {
566
- const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
567
- const poll = deps.poll || pollChangeShipped;
568
- const resolveRecord = deps.resolveRecord || resolveChangeRecordId;
569
-
570
- // 1. Resolve the active candidate (the forge slice — open-gate + diff-vs-live).
571
- const candidate = normalizeCandidate(await client.callTool("candidate_status", { repo, changeId }));
572
-
573
- // 1a. If the developer named a PR positionally (`tot ship 4`), that number is a
574
- // SAFETY assertion of WHICH PR ships — refuse (before opening any change record)
575
- // if the checkout's resolved branch-bound candidate is a DIFFERENT PR, so
576
- // `tot ship 4` can never silently ship PR #7. Only guards when both are known.
577
- const want = expectedPr != null && `${expectedPr}`.trim() ? Number(expectedPr) : null;
578
- if (want != null && candidate && candidate.prNumber != null && Number(candidate.prNumber) !== want) {
579
- console.error(
580
- fail(
581
- `you asked to ship PR #${want}, but this checkout's active candidate is PR #${candidate.prNumber}`,
582
- `switch to the branch whose PR is #${want} (or run \`tot ship\` with no number to ship this checkout's candidate)`,
583
- ),
584
- );
585
- return 1;
586
- }
587
-
588
- // 1b. Resolve the change-RECORD id (`chg_<uuid>`) that the gate + change_accept key
589
- // on — a DISTINCT namespace from the candidate handle (see the header). Only
590
- // meaningful for an OPEN candidate; for a missing/closed one we skip it and let
591
- // shipReadiness report that blocker first (never open a record we can't ship).
592
- let id = null;
593
- if (candidate && candidate.state === "open") {
594
- try {
595
- id = await resolveRecord(client, { tenant, git, changeSummary });
596
- } catch (e) {
597
- console.error(
598
- fail(
599
- `couldn't prepare a shipable change record: ${String(e?.message || e)}`,
600
- "the commit you're shipping may not have reconciled — run `tot preview` and wait for the green result, then `tot ship`",
601
- ),
602
- );
603
- return 1;
604
- }
605
- }
606
-
607
- // 2. Read the ship-readiness verdict against the RESOLVED record (or the neutral
608
- // review when there's no open candidate — shipReadiness reports the candidate).
609
- const review = id
610
- ? normalizeReview(await client.callTool("change_request_review", { id, tenant }))
611
- : NO_REVIEW;
612
- const gate = shipReadiness(candidate, review);
613
-
614
- // u16: `tot ship <N>` in a checkout that has NO candidate matching this PR (the PR
615
- // isn't yours) — fall through to the OPERATOR-by-PR queue path (resolve PR→changeId
616
- // from GET /api/changes, ship via POST /api/changes/accept) instead of dead-ending on
617
- // "no active candidate". Only fires for a POSITIONAL PR (expectedPr) with the fallback
618
- // wired (run() supplies it). The U15 different-PR guard (step 1a) still REFUSES — this
619
- // is the distinct "nothing to ship from THIS checkout" case, not a mismatch.
620
- if (gate.kind === "no-candidate" && expectedPr != null && deps.shipByPrFallback) {
621
- return await deps.shipByPrFallback();
622
- }
623
-
624
- if (gate.kind === "unauthorized") {
625
- return await handleUnauthorized(client, { id, tenant, approvers: review.approvers });
626
- }
627
- if (gate.kind !== "ready") {
628
- console.error(fail(gate.what, gate.next));
629
- return 1;
630
- }
631
-
632
- // 2. ALWAYS render the diff-vs-live, then print the EXACT plan and gate on ONE
633
- // explicit confirm via the SHARED affordance (plan.mjs). DEVELOPER context =
634
- // accept-then-deploy (merge this PR into main, THEN deploy main → preview + live).
635
- const diff = computeDiffVsLive(git, candidate.baseSha, candidate.headSha);
636
- for (const line of renderDiffVsLive({ ...diff, prUrl: candidate.url })) console.log(line);
637
-
638
- const previewUrl = review.previewUrl || candidate.url || previewUrlFor(storefrontUrl, tenant, candidate.prNumber);
639
- const planLines = planForAction({
640
- action: "ship",
641
- context: "developer",
642
- tenant,
643
- pr: candidate.prNumber,
644
- changeId: id,
645
- headSha: candidate.headSha,
646
- targets: { preview: previewUrl, live: liveUrlFor(tenant) },
647
- });
648
- // `--yes` confirms non-interactively; a non-TTY WITHOUT `--yes` refuses (never
649
- // auto-confirm a live change). Default answer is NO on an interactive prompt.
650
- const { confirmed, reason } = await confirmPlan(planLines, {
651
- yes,
652
- question: `Ship this live to ${tenant}?`,
653
- });
654
- if (!confirmed) {
655
- if (reason === "non-tty") {
656
- console.error(
657
- fail(
658
- "`tot ship` needs an interactive terminal to confirm the live change",
659
- "run it from a terminal, or pass --yes to confirm non-interactively",
660
- ),
661
- );
662
- return 2;
663
- }
664
- console.log(" Ship cancelled — nothing changed.");
665
- return 0;
666
- }
667
-
668
- // 3. Accept (the ship gate), then poll change_status until shipped.
669
- let accept;
670
- try {
671
- accept = normalizeChangeResult(
672
- await client.callTool("change_accept", {
673
- id,
674
- tenant,
675
- dryRun: false,
676
- // Stable per (candidate, head): a re-run after a blip returns the original
677
- // ship instead of double-accepting.
678
- idempotencyKey: `ship-${id}-${candidate.headSha ?? "head"}`,
679
- }),
680
- );
681
- } catch (e) {
682
- console.error(
683
- fail(
684
- `the ship gate refused to accept this change: ${String(e?.message || e)}`,
685
- "check the reconcile/compliance result with `tot preview`, then re-run `tot ship`",
686
- ),
687
- );
688
- return 1;
689
- }
690
-
691
- const progress = deps.progress === false ? null : startProgress("shipping…");
692
- let status;
693
- try {
694
- status = await poll(client, { id, tenant });
695
- } finally {
696
- progress?.stop();
697
- }
698
- return reportShipped(accept, status, { tenant, noOpen, openUrl: deps.openUrl });
699
- }
700
-
701
- /**
702
- * The unauthorised path — don't just error out. The change record was already opened
703
- * and moved to ready_for_review while resolving the ship (`resolveChangeRecordId`), so
704
- * the review request IS recorded; we just tell the developer that and print WHO can
705
- * approve. (There is no separate `change_ready` to run — re-readying a ready record is
706
- * an illegal transition; the record already sits in the reviewers' queue.)
707
- * @returns {Promise<number>} exit code (non-zero — the ship didn't happen)
708
- */
709
- async function handleUnauthorized(client, { id, tenant, approvers }) {
710
- void client;
711
- void id;
712
- console.error(
713
- fail(
714
- "you're not authorised to ship this change live yourself",
715
- "the change is recorded and ready for review — an authorised approver can ship it",
716
- ),
717
- );
718
- if (approvers.length) {
719
- console.error(` Who can approve: ${approvers.join(", ")}`);
720
- } else {
721
- console.error(` Who can approve: a teammate with ship/promote authority for ${tenant}.`);
722
- }
723
- return 1;
724
- }
725
-
726
- /** Report the shipped result and open the live URL (unless suppressed). */
727
- function reportShipped(accept, status, { tenant, noOpen, openUrl }) {
728
- const url = status?.previewUrl || accept?.previewUrl || null;
729
- if (status?.shipped || accept?.shipped) {
730
- console.log(`\n ✓ shipped ${tenant} live.`);
731
- if (url) {
732
- console.log(` Live: ${url}`);
733
- if (!noOpen && openUrl && openUrl(url)) console.log(" (opened in your browser)");
734
- }
735
- return 0;
736
- }
737
- // Accept committed but the shipped state hasn't been observed yet (a promote may
738
- // still be landing) — report honestly rather than claim a live URL.
739
- console.log(`\n ~ ship accepted for ${tenant}; it's going live now.`);
740
- if (url) console.log(` Track it here: ${url}`);
741
- return 0;
742
- }
743
-
744
- // ─── URL derivation (pure) ─────────────────────────────────────────────────────────
745
-
746
- /** The hosted preview URL for a tenant's PR, or null when the PR/base is unknown. Pure. */
747
- export function previewUrlFor(base, tenant, pr) {
748
- const origin = (base || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
749
- if (pr == null || `${pr}`.trim() === "" || !tenant) return null;
750
- return `${origin}/preview/${encodeURIComponent(tenant)}/pr/${pr}`;
751
- }
195
+ // ─── URL derivation (pure) ─────────────────────────────────────────────────────
752
196
 
753
197
  /** The live site URL for a tenant (the tenant IS its apex domain, e.g. tokenoftrust.com). Pure. */
754
198
  export function liveUrlFor(tenant) {
@@ -756,159 +200,120 @@ export function liveUrlFor(tenant) {
756
200
  return t ? `https://${t}` : null;
757
201
  }
758
202
 
759
- // ─── Operator ship-by-PR via the SHARED candidate queue (unit U16) ──────────────────
760
- //
761
- // The real user report: `tot ship 4` for a PR the operator did NOT author fails
762
- // `✗ no active candidate to ship for this checkout` — U15's developer path resolves
763
- // the candidate from the LOCAL git checkout, so it can't ship a PR that isn't yours,
764
- // and U15's operator path needed an explicit `--change-id` (it couldn't resolve
765
- // PR→changeId — the `// u9:` gap).
766
- //
767
- // The insight this unit builds on (NO forge PR-read needed): a BUILT PR already has a
768
- // `ReviewEnvironment` in the candidate index, and `GET /api/changes` (the operator
769
- // accept queue) returns the tenant's OPEN candidates — each with `changeId` +
770
- // `prNumber` + `headSha`. So PR N → changeId is resolvable from the QUEUE. The ship is
771
- // then the SAME atomic merge→main→reconcile→promote-live the `/admin` Accept button
772
- // drives — `POST /api/changes/accept` with `{ repo:<appDomain>, changeId,
773
- // expectedHeadSha }` — NOT the MCP `change_accept` (that's chg_-record-id-keyed +
774
- // checkout-bound). The go-live HUMAN GATE stays the confirm.
775
- //
776
- // AUTH (investigated): `resolveOwnerSession` (apps/storefront/src/lib/grants/session.ts)
777
- // accepts a SIGNED-IN storefront `tot_session` cookie (path 1) OR the Bearer operator
778
- // secret + `X-Tot-Owner` (path 2). The CLI holds NO storefront cookie — its OAuth login
779
- // is with the MCP, a different trust boundary — so the signed-in/OAuth path can't apply
780
- // here. The Bearer operator-secret path is the CLI's ONLY route, exactly mirroring
781
- // `tot preview build`'s transport (Bearer + `X-Tot-Owner` + `x-tot-capability`).
782
-
783
- const OPERATOR_SECRET_ENV = ["PREVIEW_RECONCILE_SECRET", "GRANTS_ADMIN_SECRET", "TOT_OPERATOR_SECRET"];
203
+ // ─── Response normalisation (defensive one endpoint, but shapes may vary) ─────
784
204
 
785
205
  /**
786
- * Resolve the operator secret from an explicit `--secret` or the env (first found),
787
- * matching `tot preview build`'s precedence. Pure given its env argument.
788
- * @param {string|null|undefined} explicit
789
- * @param {NodeJS.ProcessEnv} env
790
- * @returns {string}
206
+ * Normalise a `GET /api/changes/ship` body b09's `AggregateShipPlan` (a
207
+ * shippable aggregate) or `AggregateShipRefusal` (nothing shippable), read
208
+ * defensively since it crossed the wire as JSON. Pure — unit-tested.
209
+ * @param {any} data
210
+ * @returns {{ok:true, tenantId:string|null, pinnedSha:string|null, artifactDigest:string|null,
211
+ * includedPrs:Array<{prNumber?:number|null,changeId?:string|null,headSha?:string|null}>,
212
+ * integrationRunId:string|null, receiptId:string|null,
213
+ * rollbackTarget:{receiptId:string,aggregateSha:string,artifactDigest?:string}|null,
214
+ * paywall:{allowed:boolean,message:string|null}, alreadyShipped:boolean} |
215
+ * {ok:false, reason:string, message:string}}
791
216
  */
792
- export function resolveOperatorSecret(explicit, env = {}) {
793
- if (explicit && `${explicit}`.trim()) return `${explicit}`.trim();
794
- for (const k of OPERATOR_SECRET_ENV) {
795
- const v = env[k];
796
- if (v && `${v}`.trim()) return `${v}`.trim();
217
+ export function normalizeShipPlan(data) {
218
+ const o = data && typeof data === "object" ? data : {};
219
+ if (o.ok === true) {
220
+ return {
221
+ ok: true,
222
+ tenantId: typeof o.tenantId === "string" ? o.tenantId : null,
223
+ pinnedSha: typeof o.pinnedSha === "string" ? o.pinnedSha : null,
224
+ artifactDigest: typeof o.artifactDigest === "string" ? o.artifactDigest : null,
225
+ includedPrs: Array.isArray(o.includedPrs) ? o.includedPrs : [],
226
+ integrationRunId: typeof o.integrationRunId === "string" ? o.integrationRunId : null,
227
+ receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
228
+ rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
229
+ paywall:
230
+ o.paywall && typeof o.paywall === "object"
231
+ ? {
232
+ allowed: o.paywall.allowed === true,
233
+ message: typeof o.paywall.message === "string" ? o.paywall.message : null,
234
+ }
235
+ : { allowed: true, message: null },
236
+ alreadyShipped: o.alreadyShipped === true,
237
+ };
797
238
  }
798
- return "";
239
+ return {
240
+ ok: false,
241
+ reason: typeof o.reason === "string" ? o.reason : "unknown",
242
+ message: typeof o.message === "string" ? o.message : "the ship plan was refused for an unknown reason",
243
+ };
799
244
  }
800
245
 
801
246
  /**
802
- * Normalise a `GET /api/changes` body to the OPEN candidate list. The endpoint
803
- * answers `{ owner, capability, current, changes }` where `changes` is the open
804
- * `ReviewEnvironment` queue; read defensively (tolerate a bare array or a `{ changes }`
805
- * wrapper) and keep only records carrying a `changeId`. Pure — unit-tested.
247
+ * Normalise a `POST /api/changes/ship` body b09's `AggregateShipResult`.
248
+ * `state` is the ONLY honest terminal-state authority: render "shipped live"
249
+ * for `"shipped"` and nothing else. Pure unit-tested.
806
250
  * @param {any} data
807
- * @returns {Array<{changeId:string, prNumber?:number|null, headSha?:string|null, previewUrl?:string|null}>}
808
251
  */
809
- export function normalizeChangesQueue(data) {
810
- const list = Array.isArray(data)
811
- ? data
812
- : data && Array.isArray(data.changes)
813
- ? data.changes
814
- : [];
815
- return list.filter((c) => c && typeof c === "object" && typeof c.changeId === "string");
252
+ export function normalizeShipResult(data) {
253
+ const o = data && typeof data === "object" ? data : {};
254
+ return {
255
+ ok: o.ok === true,
256
+ state: typeof o.state === "string" ? o.state : "unknown",
257
+ reason: typeof o.reason === "string" ? o.reason : null,
258
+ message: typeof o.message === "string" ? o.message : "",
259
+ pinnedSha: typeof o.pinnedSha === "string" ? o.pinnedSha : null,
260
+ artifactDigest: typeof o.artifactDigest === "string" ? o.artifactDigest : null,
261
+ receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
262
+ rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
263
+ };
816
264
  }
817
265
 
818
- /**
819
- * Find the OPEN candidate whose forge PR number === `pr` and return the descriptor
820
- * the by-PR ship needs (`{ changeId, headSha, prNumber, previewUrl }`), else null when
821
- * no built candidate matches. Pure — unit-tested.
822
- * @param {ReturnType<typeof normalizeChangesQueue>} changes
823
- * @param {number|string} pr
824
- * @returns {{changeId:string, headSha:string|null, prNumber:number, previewUrl:string|null}|null}
825
- */
826
- export function findCandidateByPr(changes, pr) {
827
- const want = Number(pr);
828
- if (!Number.isFinite(want)) return null;
829
- for (const c of changes || []) {
830
- if (c.prNumber != null && Number(c.prNumber) === want) {
831
- return {
832
- changeId: c.changeId,
833
- headSha: c.headSha ?? null,
834
- prNumber: want,
835
- previewUrl: c.previewUrl ?? null,
836
- };
837
- }
266
+ /** A clear next step per plan/ship refusal reason. Pure. */
267
+ export function refusalNextStep(reason) {
268
+ switch (reason) {
269
+ case "not_green":
270
+ case "no_passed_run":
271
+ return "land PRs into the shared preview queue and wait for it to go green, then re-run `tot ship`";
272
+ case "sha_mismatch":
273
+ case "digest_mismatch":
274
+ return "the aggregate moved since you reviewed it — re-run `tot ship` to review the current plan";
275
+ case "artifact_missing":
276
+ return "re-integrate to re-materialize the artifact (b09 never rebuilds at ship), then re-run `tot ship`";
277
+ case "paywall":
278
+ return "upgrade the storefront subscription to enable go-live";
279
+ case "golive_unconfirmed":
280
+ return "re-run `tot ship` and confirm the plan";
281
+ default:
282
+ return "re-run `tot ship`";
838
283
  }
839
- return null;
840
284
  }
841
285
 
842
- /**
843
- * Normalise a `POST /api/changes/accept` body to { shipped, pending, state, sha,
844
- * message, error }. The atomic-accept endpoint reports an HONEST terminal `state`:
845
- * `shipped` (and `live:true`) ONLY when the target is verified live; `publish_pending`
846
- * = merged + dispatched but NOT yet verified (never a live claim). Pure — unit-tested.
847
- * @param {any} data
848
- */
849
- export function normalizeAcceptResponse(data) {
850
- const o = data && typeof data === "object" ? data : {};
851
- const state = typeof o.state === "string" ? o.state : null;
852
- const shipped = state === "shipped" || o.live === true;
853
- return {
854
- shipped,
855
- pending: !shipped && (state === "publish_pending" || o.merged === true),
856
- state,
857
- sha: typeof o.sha === "string" ? o.sha : null,
858
- message: typeof o.message === "string" ? o.message : null,
859
- error: typeof o.error === "string" ? o.error : null,
860
- raw: data,
861
- };
286
+ /** Read a fetch Response body as JSON, tolerating a non-JSON/empty body. */
287
+ async function readJsonSafe(res) {
288
+ try {
289
+ return await res.json();
290
+ } catch {
291
+ return {};
292
+ }
862
293
  }
863
294
 
295
+ // ─── Orchestration ───────────────────────────────────────────────────────────────
296
+
864
297
  /**
865
- * The OPERATOR ship-by-PR flow (unit U16). Ships ANY built PR not just your
866
- * checkout with NO forge PR-read:
298
+ * The ship flow: GET the plan, print it + confirm, POST to ship, report the
299
+ * honest terminal state. `fetch`/`confirmPlan`/`openUrl`/`progress` are
300
+ * injected so it's unit-tested with no live network/TTY.
867
301
  *
868
- * 1. RESOLVE PR N → { changeId, headSha } by reading the tenant's OPEN candidate
869
- * queue (`GET /api/changes`) and matching `prNumber === N`. If N isn't in the
870
- * queue the PR has no built candidate → instruct `tot preview build` first (we
871
- * NEVER invent a changeId).
872
- * 2. PLAN + CONFIRM — the go-live HUMAN GATE. `POST /api/changes/accept` is the
873
- * atomic merge→main + reconcile + promote-live, so the plan states BOTH (the
874
- * developer-context wording, mirroring the `/admin` Accept plan). `--yes` confirms
875
- * non-interactively; a non-TTY without `--yes` REFUSES — nothing merges/goes live
876
- * without an explicit yes.
877
- * 3. SHIP via `POST /api/changes/accept` `{ repo:<tenant appDomain>, changeId,
878
- * expectedHeadSha }` — the SAME endpoint the `/admin` Accept button uses.
879
- *
880
- * Transport mirrors `tot preview build` exactly (Bearer operator secret + `X-Tot-Owner`
881
- * + `x-tot-capability: ship-on-behalf`). `fetch`/confirm/openUrl/progress are injected
882
- * so it's unit-tested with no network/TTY.
883
- *
884
- * @param {{ tenant:string, pr:number|string|null, secret:string, storefrontUrl?:string|null,
885
- * noOpen?:boolean, yes?:boolean }} params
302
+ * @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
303
+ * yes?:boolean, noOpen?:boolean }} params
886
304
  * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
887
305
  * openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
888
306
  * @returns {Promise<number>} process exit code
889
307
  */
890
- export async function runShipByPr(
891
- { tenant, pr, secret, storefrontUrl = null, noOpen = false, yes = false },
892
- deps = {},
893
- ) {
308
+ export async function runShip({ tenant, secret, storefrontUrl = null, yes = false, noOpen = false }, deps = {}) {
894
309
  const fetchImpl = deps.fetch || globalThis.fetch;
895
310
  const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
896
311
  const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
897
312
 
898
- const want = pr != null && `${pr}`.trim() && Number.isFinite(Number(pr)) ? Number(pr) : null;
899
- if (want == null) {
900
- console.error(
901
- fail(
902
- "operator ship needs a PR number to resolve the built candidate",
903
- "pass a PR — `tot ship <N>` or `tot ship --pr <N> --tenant <appDomain>`",
904
- ),
905
- );
906
- return 2;
907
- }
908
313
  if (!secret) {
909
314
  console.error(
910
315
  fail(
911
- "shipping a PR that isn't your checkout is an OPERATOR action — it needs an operator secret",
316
+ "shipping is an OPERATOR action — it needs an operator secret",
912
317
  "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
913
318
  ),
914
319
  );
@@ -921,66 +326,61 @@ export async function runShipByPr(
921
326
  "x-tot-capability": "ship-on-behalf",
922
327
  };
923
328
 
924
- // 1. Resolve PR N { changeId, headSha } from the tenant's OPEN candidate queue.
925
- let queueRes;
329
+ // 1. GET the read-only plan: the pinned sha + artifact digest + included PRs +
330
+ // rollback target + paywall verdict. Zero side effects.
331
+ let planRes;
926
332
  try {
927
- queueRes = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
333
+ planRes = await fetchImpl(`${base}/api/changes/ship`, { method: "GET", headers: authHeaders });
928
334
  } catch (e) {
929
335
  console.error(
930
- fail(`couldn't reach the candidate queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
336
+ fail(`couldn't reach the ship plan at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
931
337
  );
932
338
  return 1;
933
339
  }
934
- const queueData = await readJsonSafe(queueRes);
935
- if (!queueRes.ok) {
936
- const msg = queueData?.error || `HTTP ${queueRes.status}`;
340
+ const planData = await readJsonSafe(planRes);
341
+ if (!planRes.ok && planData?.ok !== false) {
342
+ // A transport-level refusal the endpoint didn't explain in its own shape
343
+ // (auth/tenant-resolution/network) — surface it honestly.
344
+ const msg = planData?.error || `HTTP ${planRes.status}`;
937
345
  console.error(
938
346
  fail(
939
- `the candidate queue refused the request: ${msg}`,
940
- queueRes.status === 401 || queueRes.status === 403
347
+ `the ship plan was refused: ${msg}`,
348
+ planRes.status === 401 || planRes.status === 403
941
349
  ? "check the operator secret and that it's authorised for this tenant"
942
350
  : "check --tenant / --url, then re-run",
943
351
  ),
944
352
  );
945
353
  return 1;
946
354
  }
947
-
948
- const candidate = findCandidateByPr(normalizeChangesQueue(queueData), want);
949
- if (!candidate) {
950
- const previewUrl = previewUrlFor(base, tenant, want);
951
- console.error(
952
- fail(
953
- `PR #${want} has no built candidate in ${tenant}'s queue — there's nothing to ship`,
954
- `build it first: \`tot preview build --tenant ${tenant} --pr ${want}\`, then \`tot ship ${want} --tenant ${tenant}\``,
955
- ),
956
- );
957
- if (previewUrl) console.error(` Once built it appears at: ${previewUrl}`);
355
+ const plan = normalizeShipPlan(planData);
356
+ if (!plan.ok) {
357
+ console.error(fail(plan.message, refusalNextStep(plan.reason)));
958
358
  return 1;
959
359
  }
960
360
 
961
- // 2. Plan + confirm the go-live HUMAN GATE. `POST /api/changes/accept` is the
962
- // ATOMIC merge→main + promote-live, so the plan narrates BOTH (developer-context
963
- // wording the same "merge … then deploy" the /admin Accept plan renders).
964
- const previewUrl = candidate.previewUrl || previewUrlFor(base, tenant, want);
361
+ // 2. Print the EXACT plan via the shared U10 affordance, then require ONE
362
+ // explicit confirm (`--yes` non-interactive; a non-TTY without it refuses).
363
+ const liveUrl = liveUrlFor(tenant);
965
364
  const planLines = planForAction({
966
365
  action: "ship",
967
- context: "developer",
968
366
  tenant,
969
- pr: want,
970
- changeId: candidate.changeId,
971
- headSha: candidate.headSha,
972
- targets: { preview: previewUrl, live: liveUrlFor(tenant) },
367
+ pinnedSha: plan.pinnedSha,
368
+ artifactDigest: plan.artifactDigest,
369
+ includedPrs: plan.includedPrs,
370
+ rollbackTarget: plan.rollbackTarget,
371
+ paywall: plan.paywall,
372
+ targets: { live: liveUrl },
973
373
  });
974
374
  const { confirmed, reason } = await confirmPlan(planLines, {
975
375
  yes,
976
- question: `Ship PR #${want} live to ${tenant} (merge main, then go live)?`,
376
+ question: `Ship ${tenant}'s current green aggregate live?`,
977
377
  });
978
378
  if (!confirmed) {
979
379
  if (reason === "non-tty") {
980
380
  console.error(
981
381
  fail(
982
382
  "`tot ship` needs an interactive terminal to confirm this live change",
983
- "re-run from a terminal, or pass --yes to confirm non-interactively",
383
+ "run it from a terminal, or pass --yes to confirm non-interactively",
984
384
  ),
985
385
  );
986
386
  return 2;
@@ -989,17 +389,19 @@ export async function runShipByPr(
989
389
  return 0;
990
390
  }
991
391
 
992
- // 3. Accept it via the SAME endpoint the /admin Accept button uses (repo=appDomain).
392
+ // 3. SHIP the human go-live gate (confirmGoLive) plus the reviewed pin, so a
393
+ // STALE plan (the aggregate moved / the artifact changed since GET) is
394
+ // refused rather than shipping something nobody reviewed.
993
395
  const progress = deps.progress === false ? null : startProgress("shipping…");
994
- let acceptRes;
396
+ let shipRes;
995
397
  try {
996
- acceptRes = await fetchImpl(`${base}/api/changes/accept`, {
398
+ shipRes = await fetchImpl(`${base}/api/changes/ship`, {
997
399
  method: "POST",
998
400
  headers: { "content-type": "application/json", ...authHeaders },
999
401
  body: JSON.stringify({
1000
- repo: tenant,
1001
- changeId: candidate.changeId,
1002
- ...(candidate.headSha ? { expectedHeadSha: candidate.headSha } : {}),
402
+ confirmGoLive: true,
403
+ ...(plan.pinnedSha ? { expectedAggregateSha: plan.pinnedSha } : {}),
404
+ ...(plan.artifactDigest ? { expectedArtifactDigest: plan.artifactDigest } : {}),
1003
405
  }),
1004
406
  });
1005
407
  } catch (e) {
@@ -1009,234 +411,51 @@ export async function runShipByPr(
1009
411
  );
1010
412
  return 1;
1011
413
  }
1012
- const acceptData = await readJsonSafe(acceptRes);
414
+ const shipData = await readJsonSafe(shipRes);
1013
415
  progress?.stop();
1014
416
 
1015
- const result = normalizeAcceptResponse(acceptData);
1016
- if (!acceptRes.ok && !result.shipped && !result.pending) {
1017
- const why = result.error || result.message || `HTTP ${acceptRes.status}`;
1018
- console.error(
1019
- fail(
1020
- `the ship gate refused this change (HTTP ${acceptRes.status}): ${why}`,
1021
- "check the PR reconciled cleanly (rebuild with `tot preview build`), then re-run",
1022
- ),
1023
- );
1024
- return 1;
1025
- }
1026
- return reportShippedByPr(result, { tenant, pr: want, previewUrl, noOpen, openUrl: deps.openUrl });
1027
- }
1028
-
1029
- /** Read a fetch Response body as JSON, tolerating a non-JSON/empty body. */
1030
- async function readJsonSafe(res) {
1031
- try {
1032
- return await res.json();
1033
- } catch {
1034
- return {};
1035
- }
417
+ return reportShipResult(normalizeShipResult(shipData), { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
1036
418
  }
1037
419
 
1038
- /** Report the by-PR ship result — honest terminal state (never a false "shipped live"). */
1039
- function reportShippedByPr(result, { tenant, pr, previewUrl, noOpen, openUrl }) {
1040
- const liveUrl = liveUrlFor(tenant);
1041
- if (result.shipped) {
1042
- console.log(`\n ✓ shipped PR #${pr} live to ${tenant}.`);
420
+ /**
421
+ * Report the orchestrator's HONEST terminal state. `shipped` is the ONLY state
422
+ * rendered as "shipped live" — `refused`/`promote_failed`/`record_failed` are
423
+ * surfaced with their own message + a clear next step, never dressed up as a
424
+ * success and never silently swallowed.
425
+ * @returns {number} process exit code
426
+ */
427
+ function reportShipResult(result, { tenant, liveUrl, noOpen, openUrl }) {
428
+ if (result.state === "shipped") {
429
+ console.log(`\n ✓ shipped ${tenant} live.`);
430
+ if (result.pinnedSha) console.log(` pinned: ${result.pinnedSha}`);
431
+ if (result.receiptId) console.log(` receipt: ${result.receiptId}`);
1043
432
  if (liveUrl) {
1044
433
  console.log(` Live: ${liveUrl}`);
1045
434
  if (!noOpen && openUrl && openUrl(liveUrl)) console.log(" (opened in your browser)");
1046
435
  }
436
+ if (result.message) console.log(` ${result.message}`);
1047
437
  return 0;
1048
438
  }
1049
- // Merged + dispatched but the target isn't verified live yet — honest in-flight.
1050
- console.log(`\n ~ ship accepted for PR #${pr} on ${tenant}; it's going live now (not yet verified).`);
1051
- if (previewUrl) console.log(` Track it here: ${previewUrl}`);
1052
- return 0;
1053
- }
1054
-
1055
- // ─── Operator ship (DEPLOY-only) ─────────────────────────────────────────────────────
1056
-
1057
- /**
1058
- * The OPERATOR ship flow (decision ship-context-dependent-semantics): the caller is
1059
- * targeting a `--pr N --tenant t` that ISN'T their checkout, so ship means DEPLOY
1060
- * (main → preview + live) — NOT merge. The merge is the separate `tot accept` verb.
1061
- *
1062
- * We STATE the exact deploy plan (the shared `planForAction`/`printPlanAndConfirm`
1063
- * affordance) and confirm before acting (`--yes` to confirm non-interactively; a
1064
- * non-TTY without `--yes` refuses).
1065
- *
1066
- * ┌─ CROSS-TENANT/CROSS-PR TARGETING (the app-bound-identity constraint) ────────────┐
1067
- * │ Resolving a TARGETED PR's change-record id / head sha from the forge needs a │
1068
- * │ `candidate_status` read the CLI's user identity may NOT be allowed (it's │
1069
- * │ app-bound). So we take the change-record id (and head sha) via EXPLICIT flags │
1070
- * │ (`--change-id` / `--head-sha`) rather than blocking — mirroring U3's descriptor │
1071
- * │ flags. See the `u9` marker below for the auto-resolution follow-on. │
1072
- * └───────────────────────────────────────────────────────────────────────────────────┘
1073
- *
1074
- * @param {{callTool:Function}} client an MCP client (real or mock)
1075
- * @param {{ tenant:string, pr?:number|string|null, changeId?:string|null,
1076
- * headSha?:string|null, storefrontUrl?:string|null, noOpen?:boolean, yes?:boolean }} params
1077
- * @param {{ confirmPlan?:typeof printPlanAndConfirm, poll?:typeof pollChangeShipped,
1078
- * openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
1079
- * @returns {Promise<number>} process exit code
1080
- */
1081
- export async function runShipOperator(
1082
- client,
1083
- { tenant, pr = null, changeId = null, headSha = null, storefrontUrl = null, noOpen = false, yes = false },
1084
- deps = {},
1085
- ) {
1086
- const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
1087
- const poll = deps.poll || pollChangeShipped;
1088
-
1089
- // u9: auto-resolve targeted PR descriptor (changeId/headSha) via forge PR-read —
1090
- // the CLI's app-bound user identity may not be allowed the candidate_status read for
1091
- // a cross-tenant/cross-PR target, so today the operator supplies these via flags.
1092
- const previewUrl = previewUrlFor(storefrontUrl, tenant, pr);
1093
- const planLines = planForAction({
1094
- action: "ship",
1095
- context: "operator",
1096
- tenant,
1097
- pr,
1098
- changeId,
1099
- headSha,
1100
- targets: { preview: previewUrl, live: liveUrlFor(tenant) },
1101
- });
1102
- const { confirmed, reason } = await confirmPlan(planLines, {
1103
- yes,
1104
- question: `Deploy ${tenant} live now (main → preview + live)?`,
1105
- });
1106
- if (!confirmed) {
1107
- if (reason === "non-tty") {
1108
- console.error(
1109
- fail(
1110
- "`tot ship` needs an interactive terminal to confirm this deploy",
1111
- "re-run from a terminal, or pass --yes to confirm non-interactively",
1112
- ),
1113
- );
1114
- return 2;
1115
- }
1116
- console.log(" Ship cancelled — nothing deployed.");
1117
- return 0;
1118
- }
1119
-
1120
- // We can't auto-resolve the targeted PR's change record from the forge (see above),
1121
- // so the operator must name it explicitly to promote it.
1122
- if (!changeId) {
1123
- console.error(
1124
- fail(
1125
- "operator ship needs the target change-record id to deploy",
1126
- "pass --change-id <chg_…> (u9 will auto-resolve it from --pr via a forge PR-read)",
1127
- ),
1128
- );
1129
- return 2;
1130
- }
1131
-
1132
- // TODO(u9): operator ship is DEPLOY-only (the merge is the separate `tot accept`),
1133
- // but a DISTINCT deploy-only trigger separate from `change_accept` is NOT yet wired.
1134
- // Until it is, this uses the EXISTING promote path — `change_accept` (u7's
1135
- // promote-by-digest, keyed on the resolved change-record id) — which is the same
1136
- // mechanism the developer path calls. Adding a new deploy path is out of scope for u4.
1137
- let accept;
1138
- try {
1139
- accept = normalizeChangeResult(
1140
- await client.callTool("change_accept", {
1141
- id: changeId,
1142
- tenant,
1143
- dryRun: false,
1144
- idempotencyKey: `ship-${changeId}-${headSha ?? "head"}`,
1145
- }),
1146
- );
1147
- } catch (e) {
1148
- console.error(
1149
- fail(
1150
- `the deploy gate refused this change: ${String(e?.message || e)}`,
1151
- "check the change is merged to main and reconciled, then re-run `tot ship`",
1152
- ),
1153
- );
439
+ if (result.state === "refused") {
440
+ console.error(fail(result.message || `ship refused (${result.reason})`, refusalNextStep(result.reason)));
1154
441
  return 1;
1155
442
  }
1156
-
1157
- const progress = deps.progress === false ? null : startProgress("deploying…");
1158
- let status;
1159
- try {
1160
- status = await poll(client, { id: changeId, tenant });
1161
- } finally {
1162
- progress?.stop();
1163
- }
1164
- return reportShipped(accept, status, { tenant, noOpen, openUrl: deps.openUrl });
1165
- }
1166
-
1167
- /**
1168
- * The `run` wrapper for OPERATOR ship — establish a session, bind the target tenant,
1169
- * and drive `runShipOperator`. Split from `run` for the same reason the developer half
1170
- * is split from `runShip`: this half does the I/O (auth + tenant switch), the pure flow
1171
- * is unit-tested with a mock client.
1172
- * @param {ReturnType<typeof parseShipArgs>} args
1173
- * @param {any} ctx
1174
- * @param {NodeJS.ProcessEnv} env
1175
- */
1176
- async function runOperatorFlow(args, ctx, env) {
1177
- const tenant = (args.tenant || (ctx.mode === "checkout" ? ctx.tenant : null) || "").trim();
1178
- if (!tenant) {
1179
- console.error(
1180
- fail(
1181
- "operator ship needs a target tenant",
1182
- "pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com) with --pr <N>",
1183
- ),
1184
- );
1185
- return 2;
1186
- }
1187
- const pr = args.pr != null && `${args.pr}`.trim() && Number.isFinite(Number(args.pr)) ? Number(args.pr) : args.pr;
1188
- const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
1189
-
1190
- // u16: with NO explicit change-record id, ship ANY built PR via the SHARED candidate
1191
- // queue — resolve PR→changeId from GET /api/changes and ship via POST
1192
- // /api/changes/accept (operator secret transport). This is the path that ships a PR
1193
- // the operator did NOT author. The explicit `--change-id` path below stays the MCP
1194
- // change_accept targeting (backward-compatible; distinct chg_-record namespace).
1195
- if (!args.changeId) {
1196
- return await runShipByPr(
1197
- {
1198
- tenant,
1199
- pr,
1200
- secret: resolveOperatorSecret(args.secret, env),
1201
- storefrontUrl,
1202
- noOpen: args.noOpen,
1203
- yes: args.yes,
1204
- },
1205
- { openUrl: (u) => openBrowser(u) },
1206
- );
1207
- }
1208
-
1209
- const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
1210
- const client = createMcpClient(baseUrl);
1211
- try {
1212
- await establishSession(client, { env, prefer: args.identity || undefined });
1213
- await client.callTool("client_switch", { tenant });
1214
- return await runShipOperator(
1215
- client,
1216
- {
1217
- tenant,
1218
- pr,
1219
- changeId: args.changeId,
1220
- headSha: args.headSha,
1221
- storefrontUrl,
1222
- noOpen: args.noOpen,
1223
- yes: args.yes,
1224
- },
1225
- { openUrl: (u) => openBrowser(u) },
1226
- );
1227
- } catch (e) {
1228
- if (e instanceof AuthUnavailableError) {
1229
- console.error(fail("sign in to ship", e.hint || "run `tot login`, then re-run `tot ship`"));
1230
- return 1;
1231
- }
443
+ if (result.state === "promote_failed" || result.state === "record_failed") {
1232
444
  console.error(
1233
445
  fail(
1234
- `couldn't reach the ship service: ${String(e?.message || e)}`,
1235
- "check your connection and that you're signed in, then re-run",
446
+ result.message || `ship ${result.state.replace("_", " ")}`,
447
+ "re-run `tot ship` the ship is idempotent and safe to retry",
1236
448
  ),
1237
449
  );
1238
450
  return 1;
1239
451
  }
452
+ console.error(
453
+ fail(
454
+ `unexpected ship response (state: ${result.state})`,
455
+ "re-run `tot ship`; check the storefront logs if it persists",
456
+ ),
457
+ );
458
+ return 1;
1240
459
  }
1241
460
 
1242
461
  /**
@@ -1251,127 +470,29 @@ export async function run(argv, ctx) {
1251
470
  return 0;
1252
471
  }
1253
472
 
1254
- // Context-dependent (decision ship-context-dependent-semantics): targeting a
1255
- // `--pr`/`--tenant` that isn't your checkout OPERATOR (deploy-only); otherwise,
1256
- // inside your own checkout → DEVELOPER (accept + deploy). Never guessed silently
1257
- // both branches print the concrete plan for THAT context and confirm.
1258
- const ctxTenant = ctx.mode === "checkout" ? ctx.tenant : null;
1259
- const context = detectShipContext({
1260
- pr: args.pr,
1261
- prPositional: args.prPositional,
1262
- tenant: args.tenant,
1263
- ctxTenant,
1264
- ctxMode: ctx.mode,
1265
- });
1266
- if (context === "operator") {
1267
- return await runOperatorFlow(args, ctx, env);
1268
- }
1269
-
1270
- if (ctx.mode !== "checkout") {
473
+ // ONE meaning, no context detection: the target tenant is either named
474
+ // explicitly (`--tenant`, works from anywhere no checkout needed) or
475
+ // inferred from the current checkout. Never guessed from a PR/branch/actor.
476
+ const tenant = (args.tenant || (ctx.mode === "checkout" ? ctx.tenant : null) || "").trim();
477
+ if (!tenant) {
1271
478
  console.error(
1272
479
  fail(
1273
- "`tot ship` runs from inside a tenant checkout",
1274
- "tot clone <tenant> <dir> (then `cd` in, `tot preview`, and `tot ship`)",
480
+ "`tot ship` needs a target tenant",
481
+ "run it from inside a `tot clone`d store, or pass --tenant <appDomain>",
1275
482
  ),
1276
483
  );
1277
484
  return 2;
1278
485
  }
1279
486
 
1280
- const workspace = ctx.workspacePath;
1281
- const tenant = ctx.tenant;
1282
- const git = (cargs) =>
1283
- execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
1284
- const gitSafe = (cargs) => {
1285
- try {
1286
- return git(cargs);
1287
- } catch {
1288
- return "";
1289
- }
1290
- };
1291
-
1292
- const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
1293
- if (!repo) {
1294
- console.error(
1295
- fail("couldn't derive the forge repo from this checkout's remote", "run this from a `tot clone`d store"),
1296
- );
1297
- return 1;
1298
- }
1299
-
1300
- const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
1301
- const statePath = defaultCandidateStatePath(env);
1302
- const client = createMcpClient(baseUrl);
1303
- try {
1304
- const session = await establishSession(client, { env, prefer: args.identity || undefined });
1305
- // Bind the active tenant so the candidate/change tools read the right scope.
1306
- await client.callTool("client_switch", { tenant });
1307
-
1308
- // Resolve which candidate this checkout ships (the u4 seam) — branch-bound so
1309
- // ship promotes exactly the candidate `tot preview` pushed from THIS branch.
1310
- const changeId = resolveActiveChangeId({
1311
- statePath,
1312
- mcpUrl: baseUrl,
1313
- repo,
487
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
488
+ return await runShip(
489
+ {
1314
490
  tenant,
1315
- actorKey: actorKeyFor(session),
1316
- branch: currentBranch(gitSafe),
1317
- });
1318
-
1319
- // The title/body the change RECORD is opened with — from the HEAD commit subject
1320
- // (the reviewed commit's own message), so the approver's record isn't blank.
1321
- const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
1322
- const changeSummary = buildChangeSummary({ message: headSubject, headSubject });
1323
-
1324
- const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
1325
- return await runShip(
1326
- client,
1327
- {
1328
- tenant,
1329
- changeId,
1330
- repo,
1331
- git,
1332
- noOpen: args.noOpen,
1333
- yes: args.yes,
1334
- storefrontUrl,
1335
- changeSummary,
1336
- // A positional `tot ship 4` asserts WHICH PR — passed as a safety guard so
1337
- // ship refuses if this checkout's active candidate is a different PR.
1338
- expectedPr: args.prPositional ? args.pr : null,
1339
- },
1340
- {
1341
- openUrl: (u) => openBrowser(u),
1342
- // u16: when `tot ship <N>` names a PR that ISN'T this checkout's candidate (no
1343
- // local candidate resolves), fall through to the operator-by-PR queue path — so
1344
- // `tot ship 4` ships someone else's built PR too, using this checkout's tenant.
1345
- ...(args.prPositional
1346
- ? {
1347
- shipByPrFallback: () =>
1348
- runShipByPr(
1349
- {
1350
- tenant,
1351
- pr: args.pr,
1352
- secret: resolveOperatorSecret(args.secret, env),
1353
- storefrontUrl,
1354
- noOpen: args.noOpen,
1355
- yes: args.yes,
1356
- },
1357
- { openUrl: (u) => openBrowser(u) },
1358
- ),
1359
- }
1360
- : {}),
1361
- },
1362
- );
1363
- } catch (e) {
1364
- if (e instanceof AuthUnavailableError) {
1365
- console.error(fail("sign in to ship", e.hint || "run `tot login`, then re-run `tot ship`"));
1366
- return 1;
1367
- }
1368
- console.error(
1369
- fail(
1370
- `couldn't reach the ship service: ${String(e?.message || e)}`,
1371
- "check your connection and that you're signed in, then re-run",
1372
- ),
1373
- );
1374
- return 1;
1375
- }
491
+ secret: resolveOperatorSecret(args.secret, env),
492
+ storefrontUrl,
493
+ yes: args.yes,
494
+ noOpen: args.noOpen,
495
+ },
496
+ { openUrl: (u) => openBrowser(u) },
497
+ );
1376
498
  }
1377
-