@tokenoftrust/cli 1.4.0-rc.10 → 1.4.0-rc.12

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,53 +1,1015 @@
1
1
  /**
2
- * `tot ship` — promote a reconciled PREVIEW live (the ship gate).
2
+ * `tot ship` — deploy main preview + live (decisions operator-verb-and-hosting-model
3
+ * + ship-context-dependent-semantics). The deliberate ship gate.
3
4
  *
4
- * This is the second half of the dev → preview → ship loop:
5
+ * The second half of the dev → preview → ship loop:
5
6
  *
6
7
  * tot dev run your store locally with save→reload
7
8
  * tot preview push it to a reviewable preview (validate → reconcile → compliance)
8
- * tot ship promote a reconciled preview live ← you are here
9
+ * tot ship accept + deploy (developer) / deploy (operator) ← you are here
9
10
  *
10
- * STATUS: STUB the command is registered and named so the loop reads end-to-end,
11
- * but the real ship logic (resolve the current candidate/change for this checkout,
12
- * show the diff about to go live, confirm, then `change_accept` / `candidate_accept`
13
- * via the MCP, and report the shipped result) lands in a later unit.
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:
14
16
  *
15
- * TODO(u3): implement the real ship flow. Symmetric with submit.mjs's preview flow
16
- * it should reuse the same candidate handle (deriveChangeId / the active-candidate
17
- * state in candidate-state.mjs) so `tot ship` promotes the exact preview `tot preview`
18
- * last pushed, gate on a clean reconcile, and diff-confirm before accepting.
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`.
19
21
  *
20
- * Until then this prints a clear "not yet wired" message pointing at the reviewer
21
- * path that ships a change today, and exits non-zero so nothing mistakes it for a
22
- * completed ship. Additive + non-breaking: it performs no action.
22
+ * The DEVELOPER contract, deliberately strict because this changes the LIVE site:
23
23
  *
24
- * Dependency-free.
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.
37
+ *
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.
41
+ *
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.
56
+ *
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.
25
59
  */
60
+ import { execFileSync } from "node:child_process";
61
+ import { createMcpClient } from "../mcp.mjs";
62
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
26
63
  import { fail } from "../errors.mjs";
64
+ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
65
+ import { startProgress } from "../progress.mjs";
66
+ 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
+
81
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
82
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
83
+
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: deploy a PR that isn't your checkout
90
+ tot ship --change-id <chg_…> target an explicit change record (operator targeting)
91
+ tot ship --yes confirm non-interactively (skip the [y/N] prompt)
92
+ tot ship --identity <id> sign in as a specific identity for this ship
93
+ tot ship --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
27
94
 
28
- const USAGE = `tot ship promote a reconciled preview live
95
+ \`tot ship\` is CONTEXT-DEPENDENT (decision ship-context-dependent-semantics):
29
96
 
30
- tot ship (coming soon) promote the preview you last pushed with \`tot preview\`
97
+ DEVELOPER run in your OWN store checkout on a branch: ship = ACCEPT (merge
98
+ this branch/PR into main) AND THEN DEPLOY (main → preview + live). A POSITIONAL
99
+ \`tot ship 4\` / \`tot ship pr/4\` names the PR to ship — same developer semantics,
100
+ the tenant is inferred from the checkout.
101
+ • OPERATOR — target a \`--pr N --tenant t\` that ISN'T your checkout: ship = DEPLOY
102
+ only (the merge is the separate \`tot accept\` verb).
31
103
 
32
- Ship the preview live once it has reconciled cleanly. This is the deliberate
33
- step AFTER \`tot preview\` preview makes it reviewable; ship makes it live.`;
104
+ It NEVER silently guesses: it prints the EXACT plan for THAT context and confirms
105
+ first (\`--yes\` to confirm non-interactively; a non-TTY without \`--yes\` refuses).
106
+ Merge-to-main / go-live / deploy stay HUMAN GATES. If you can't approve the ship
107
+ yourself, it records the request and tells you who can.`;
34
108
 
35
109
  /**
36
- * @param {string[]} argv
37
- * @param {any} _ctx
110
+ * Parse a POSITIONAL PR token — `4`, `pr/4`, `pr#4`, `pr-4`, `#4`, `PR/4` — to the
111
+ * PR number as a string, or null when the token isn't a PR reference. Pure. Used so
112
+ * `tot ship 4` / `tot ship pr/4` name a PR positionally, the same value the `--pr`
113
+ * flag carries (but developer-context — see `detectShipContext`).
114
+ * @param {string} token
115
+ * @returns {string|null}
38
116
  */
39
- export async function run(argv, _ctx) {
40
- if (argv.includes("--help") || argv.includes("-h")) {
41
- console.log(USAGE);
117
+ export function parsePrToken(token) {
118
+ if (typeof token !== "string") return null;
119
+ const m = token.trim().match(/^(?:pr)?[/#-]?(\d+)$/i);
120
+ return m ? m[1] : null;
121
+ }
122
+
123
+ /** Parse `tot ship` argv. Pure. */
124
+ export function parseShipArgs(argv) {
125
+ const a = {
126
+ mcp: null,
127
+ identity: null,
128
+ noOpen: false,
129
+ yes: false,
130
+ pr: null,
131
+ // True when `pr` came from a POSITIONAL token (`tot ship 4`) rather than the
132
+ // `--pr` flag. A positional PR in your OWN checkout is DEVELOPER intent (ship my
133
+ // own PR — accept+deploy); the `--pr` flag stays OPERATOR targeting (deploy-only).
134
+ prPositional: false,
135
+ tenant: null,
136
+ changeId: null,
137
+ headSha: null,
138
+ url: null,
139
+ help: false,
140
+ };
141
+ for (let i = 0; i < argv.length; i++) {
142
+ const t = argv[i];
143
+ if (t === "--mcp") a.mcp = argv[++i];
144
+ else if (t === "--identity") a.identity = argv[++i];
145
+ else if (t === "--no-open") a.noOpen = true;
146
+ else if (t === "--yes" || t === "-y") a.yes = true;
147
+ else if (t === "--pr") a.pr = argv[++i];
148
+ else if (t === "--tenant") a.tenant = argv[++i];
149
+ else if (t === "--change-id") a.changeId = argv[++i];
150
+ else if (t === "--head-sha" || t === "--head") a.headSha = argv[++i];
151
+ else if (t === "--url") a.url = argv[++i];
152
+ else if (t === "--help" || t === "-h") a.help = true;
153
+ else if (!t.startsWith("-") && a.pr == null) {
154
+ // A POSITIONAL PR reference (`tot ship 4` / `tot ship pr/4` / `tot ship #4`).
155
+ // Only the first one wins; an explicit `--pr` flag (set above) takes precedence.
156
+ const n = parsePrToken(t);
157
+ if (n != null) {
158
+ a.pr = n;
159
+ a.prPositional = true;
160
+ }
161
+ }
162
+ }
163
+ return a;
164
+ }
165
+
166
+ /**
167
+ * Decide ship's CONTEXT (decision ship-context-dependent-semantics). Pure — no I/O.
168
+ *
169
+ * - "developer": run inside your OWN store checkout with no cross-target flags →
170
+ * ship = ACCEPT (merge this branch/PR into main) AND THEN DEPLOY (main → preview
171
+ * + live). This is the happy-path `tot ship` with no flags.
172
+ * - "operator": you're targeting a specific `--pr`/`--tenant` that ISN'T your active
173
+ * checkout → ship = DEPLOY only (the merge is the separate `tot accept` verb).
174
+ *
175
+ * Ship NEVER silently guesses which it is — this classifies from the EXPLICIT signals
176
+ * (an explicit `--pr`, a `--tenant` that differs from the checkout, or a `--tenant`
177
+ * named from outside any checkout), and the caller then prints the concrete plan for
178
+ * THAT context and confirms.
179
+ *
180
+ * A POSITIONAL PR (`tot ship 4`) is the exception the `--pr` FLAG is not: it names the
181
+ * developer's OWN PR to ship from their OWN checkout, so — absent any cross-target
182
+ * signal (a differing `--tenant`, or being named from outside a checkout) — it stays
183
+ * DEVELOPER (accept+deploy). The `--pr` flag remains OPERATOR targeting (deploy-only),
184
+ * per its documented "a PR that isn't your checkout" contract.
185
+ *
186
+ * @param {{ pr?: number|string|null, prPositional?: boolean, tenant?: string|null,
187
+ * ctxTenant?: string|null, ctxMode?: string|null }} p
188
+ * @returns {"developer"|"operator"}
189
+ */
190
+ export function detectShipContext({ pr = null, prPositional = false, tenant = null, ctxTenant = null, ctxMode = null } = {}) {
191
+ const hasPr = pr != null && `${pr}`.trim() !== "";
192
+ const t = (tenant || "").trim();
193
+ const ct = (ctxTenant || "").trim();
194
+ const tenantDiffers = Boolean(t && ct && t !== ct);
195
+ const targetingFromOutside = Boolean(t && ctxMode !== "checkout");
196
+ // A positional `tot ship 4` inside your OWN checkout, with no cross-target flags, is
197
+ // the developer shipping their own PR — accept+deploy, NOT operator deploy-only.
198
+ const positionalOwnPr = hasPr && prPositional && ctxMode === "checkout" && !tenantDiffers && !targetingFromOutside;
199
+ if (positionalOwnPr) return "developer";
200
+ if (hasPr || tenantDiffers || targetingFromOutside) return "operator";
201
+ return "developer";
202
+ }
203
+
204
+ // ─── Response normalisation (defensive — one MCP, but shapes may vary) ───────────
205
+
206
+ /**
207
+ * Normalise a `candidate_status` result (a single ReviewEnvironment forge slice)
208
+ * to the fields ship gates on. Returns null when there's no candidate to act on.
209
+ * Pure — unit-tested.
210
+ * @param {any} r
211
+ * @returns {{changeId?:string, state:string|null, headSha:string|null, baseSha:string|null,
212
+ * prNumber:number|null, url:string|null, mergeable:unknown}|null}
213
+ */
214
+ export function normalizeCandidate(r) {
215
+ // `tot pr` already learned candidate_status can answer as an object, a
216
+ // `{candidates:[…]}` list, or a single record — reuse that tolerance here.
217
+ const c = Array.isArray(r)
218
+ ? r[0]
219
+ : r && Array.isArray(r.candidates)
220
+ ? r.candidates[0]
221
+ : r && Array.isArray(r.environments)
222
+ ? r.environments[0]
223
+ : r;
224
+ if (!c || typeof c !== "object" || typeof c.changeId !== "string") return null;
225
+ return {
226
+ changeId: c.changeId,
227
+ state: typeof c.state === "string" ? c.state : null,
228
+ headSha: c.headSha ?? null,
229
+ baseSha: c.baseSha ?? null,
230
+ prNumber: typeof c.prNumber === "number" ? c.prNumber : null,
231
+ url: c.url ?? null,
232
+ mergeable: c.mergeable,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Normalise a `change_request_review` result to the precondition signals ship
238
+ * needs: is the evidence green (reconciled + promotable), is THIS session allowed
239
+ * to promote, and — for the unauthorised path — who can. Field names are read
240
+ * defensively (the CLI is the first caller of this tool) so a plausible rename
241
+ * degrades gracefully rather than crashing the gate. `reconciled`/`authorized` are
242
+ * TRI-STATE-ish: they're only `true` when the server clearly says so, so an
243
+ * unrecognised shape fails CLOSED (we don't ship on ambiguity). Pure — unit-tested.
244
+ * @param {any} r
245
+ * @returns {{reconciled:boolean, authorized:boolean, approvers:string[],
246
+ * previewUrl:string|null, detail:string|null, raw:any}}
247
+ */
248
+ export function normalizeReview(r) {
249
+ const o = (r && typeof r === "object" ? r : {});
250
+ const ev = o.evidence && typeof o.evidence === "object" ? o.evidence : {};
251
+ // Evidence green ⇒ reconciled + promotable. Accept the several plausible flags.
252
+ const reconciled = firstBool([
253
+ o.evidenceGreen, o.green, o.promotable, ev.green, ev.ok, ev.promotable,
254
+ o.verdict === "green" || o.verdict === "pass" ? true : undefined,
255
+ ]);
256
+ const authorized = firstBool([
257
+ o.authorized, o.authorised, o.canPromote, o.canApprove, o.authorisedToPromote,
258
+ ]);
259
+ const approvers = normalizeApprovers(
260
+ o.approvers ?? o.eligibleApprovers ?? o.whoCanApprove ?? o.promoters ?? [],
261
+ );
262
+ return {
263
+ reconciled: reconciled === true,
264
+ authorized: authorized === true,
265
+ approvers,
266
+ previewUrl: o.previewUrl ?? ev.previewUrl ?? null,
267
+ detail: typeof o.detail === "string" ? o.detail : (typeof o.reason === "string" ? o.reason : null),
268
+ raw: r,
269
+ };
270
+ }
271
+
272
+ /** First non-undefined boolean in a list, else undefined. Pure. */
273
+ function firstBool(candidates) {
274
+ for (const v of candidates) if (typeof v === "boolean") return v;
275
+ return undefined;
276
+ }
277
+
278
+ /** Coerce an approvers payload (strings, or objects with email/name) to a label list. Pure. */
279
+ export function normalizeApprovers(list) {
280
+ if (!Array.isArray(list)) return [];
281
+ return list
282
+ .map((a) => (typeof a === "string" ? a : a && (a.email || a.name || a.label || a.id)))
283
+ .filter((s) => typeof s === "string" && s.trim())
284
+ .map((s) => s.trim());
285
+ }
286
+
287
+ /**
288
+ * Normalise a `change_accept` / `change_status` result to { shipped, state,
289
+ * previewUrl, shippedAt }. `shipped` is true once the record reports the shipped
290
+ * state (or carries a shipped stamp). Pure — unit-tested.
291
+ * @param {any} r
292
+ */
293
+ export function normalizeChangeResult(r) {
294
+ const o = (r && typeof r === "object" ? r : {});
295
+ const state = typeof o.state === "string" ? o.state : null;
296
+ const shippedStamp = o.shipped && typeof o.shipped === "object" ? o.shipped : null;
297
+ const shipped = state === "shipped" || Boolean(o.shipped === true || shippedStamp);
298
+ return {
299
+ shipped,
300
+ state,
301
+ previewUrl: o.previewUrl ?? shippedStamp?.previewUrl ?? o.liveUrl ?? null,
302
+ shippedAt: shippedStamp?.shippedAt ?? o.shippedAt ?? null,
303
+ raw: r,
304
+ };
305
+ }
306
+
307
+ // ─── The candidate-resolution seam (u4 extends THIS) ─────────────────────────────
308
+
309
+ /**
310
+ * Resolve the ACTIVE candidate's changeId for this checkout — the SAME resolution
311
+ * `tot preview` uses so ship promotes exactly what preview last pushed: the
312
+ * remembered active pointer (a prior `--new` / roll), else the STABLE
313
+ * per-developer-per-tenant default (`deriveChangeId`).
314
+ *
315
+ * ┌─ SEAM (u4) ────────────────────────────────────────────────────────────────┐
316
+ * │ This is the ONE place ship keys the candidate on (tenant, actor, branch). │
317
+ * │ u4 (branch-bound candidates) keys HERE — folding in the current git branch — │
318
+ * │ so `tot preview` and `tot ship` resolve the SAME branch-bound candidate, │
319
+ * │ WITHOUT touching the gate, diff, confirm, or accept flow below. On the │
320
+ * │ default branch `branch` is null and this is byte-identical to pre-u4. │
321
+ * └──────────────────────────────────────────────────────────────────────────────┘
322
+ * Pure given its inputs (state is read through candidate-state.mjs). Exported so a
323
+ * follow-on unit can wrap/replace it.
324
+ * @param {{ statePath:string, mcpUrl:string, repo:string, tenant:string, actorKey:string,
325
+ * branch?:string|null }} keys
326
+ * @returns {string}
327
+ */
328
+ export function resolveActiveChangeId({ statePath, mcpUrl, repo, tenant, actorKey, branch = null }) {
329
+ const stable = deriveChangeId(tenant, actorKey, branch);
330
+ const active = readActiveChangeId(statePath, { mcpUrl, repo, branch });
331
+ return active || stable;
332
+ }
333
+
334
+ /**
335
+ * Normalise a `change_open` result to its minted change-record id. `change_open`
336
+ * returns the record id flat in `data` (id, state); read it defensively. Pure.
337
+ * @param {any} r
338
+ * @returns {{ id: string|null, state: string|null }}
339
+ */
340
+ export function normalizeOpenedChange(r) {
341
+ const o = r && typeof r === "object" ? r : {};
342
+ return {
343
+ id: typeof o.id === "string" && o.id ? o.id : null,
344
+ state: typeof o.state === "string" ? o.state : null,
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Resolve the change-RECORD id (`chg_<uuid>`) that the ship gate + `change_accept`
350
+ * key on — a DISTINCT namespace from the candidate handle (see the module header).
351
+ * There is NO candidate→record lookup on the wire (`change_status` lists only
352
+ * id/title/state, never the head sha), so we CAPTURE the id at `change_open`, the
353
+ * option u7's decision (ship-path-wire-resolutions) records as supported:
354
+ *
355
+ * 1. read the reviewed commit — the local HEAD, the commit `tot preview` pushed and
356
+ * the reconcile evidence is keyed on;
357
+ * 2. `change_open` it with `commit:` set, so u7's promote-by-digest can content-
358
+ * address the exact reviewed tree (absent it, `change_accept` fails closed
359
+ * `no_reviewed_commit`);
360
+ * 3. `change_ready` it, attaching the AUTHORITATIVE reconcile evidence keyed by
361
+ * (tenant, commit). This REQUIRES that commit to have reconciled — if it hasn't,
362
+ * `change_ready` throws (no authoritative evidence) and we propagate it so the
363
+ * caller refuses with a "run `tot preview` first" next step (never ship a commit
364
+ * whose evidence isn't in).
365
+ *
366
+ * Returns the resolved `chg_` id. Throws if the record can't be opened/readied — the
367
+ * caller fails CLOSED (never falls back to shipping the candidate handle or no record).
368
+ * The `change_*` calls go through the same client; `git` is injected.
369
+ * @param {{callTool:Function}} client
370
+ * @param {{ tenant:string, git:(args:string[])=>string,
371
+ * changeSummary?:{ title?:string, body?:string[] } }} params
372
+ * @returns {Promise<string>}
373
+ */
374
+ export async function resolveChangeRecordId(client, { tenant, git, changeSummary }) {
375
+ const commit = git(["rev-parse", "HEAD"]).trim();
376
+ if (!commit) {
377
+ throw new Error("couldn't read the reviewed commit (git HEAD) for this checkout");
378
+ }
379
+ const title = (changeSummary?.title || `Ship ${tenant} store change`).slice(0, 200);
380
+ const body = changeSummary?.body?.length ? changeSummary.body.join("\n").slice(0, 2000) : undefined;
381
+ const opened = normalizeOpenedChange(
382
+ await client.callTool("change_open", {
383
+ title,
384
+ ...(body ? { summary: body } : {}),
385
+ commit,
386
+ tenant,
387
+ dryRun: false,
388
+ }),
389
+ );
390
+ if (!opened.id) {
391
+ throw new Error("change_open did not return a change-record id");
392
+ }
393
+ // Ready it against the authoritative reconcile evidence for THIS exact commit —
394
+ // throws (no_authoritative_evidence) if the commit hasn't reconciled yet.
395
+ await client.callTool("change_ready", { id: opened.id, tenant, dryRun: false });
396
+ return opened.id;
397
+ }
398
+
399
+ /** A neutral review used only when there's no open candidate to resolve a record for
400
+ * — shipReadiness reports the candidate blocker first and never reads these. */
401
+ export const NO_REVIEW = Object.freeze({
402
+ reconciled: false, authorized: false, approvers: [], previewUrl: null, detail: null, raw: null,
403
+ });
404
+
405
+ // ─── The ship gate (pure) ────────────────────────────────────────────────────────
406
+
407
+ /**
408
+ * Decide, from the resolved candidate + the review verdict, whether the ship may
409
+ * proceed — the correctness-critical core, kept PURE so every branch is unit-tested
410
+ * without a client/TTY/git. Order matters: OPEN, then RECONCILED, then AUTHORISED,
411
+ * so the developer always sees the most fundamental blocker first.
412
+ *
413
+ * @param {ReturnType<typeof normalizeCandidate>} candidate
414
+ * @param {ReturnType<typeof normalizeReview>} review
415
+ * @returns {{ kind: "no-candidate"|"not-open"|"not-reconciled"|"unauthorized"|"ready",
416
+ * what?: string, next?: string }}
417
+ */
418
+ export function shipReadiness(candidate, review) {
419
+ if (!candidate) {
420
+ return {
421
+ kind: "no-candidate",
422
+ what: "no active candidate to ship for this checkout",
423
+ next: "push one first with `tot preview`, then `tot ship`",
424
+ };
425
+ }
426
+ if (candidate.state !== "open") {
427
+ // Terminal (merged/closed) vs simply-not-open both mean "nothing OPEN to ship".
428
+ const terminal = isTerminalCandidateState(candidate.state);
429
+ return {
430
+ kind: "not-open",
431
+ what: terminal
432
+ ? `this candidate is already ${candidate.state} — there's nothing open to ship`
433
+ : `this candidate isn't open (state: ${candidate.state ?? "unknown"})`,
434
+ next: "`tot preview` to push a fresh candidate, then `tot ship`",
435
+ };
436
+ }
437
+ if (!review.reconciled) {
438
+ return {
439
+ kind: "not-reconciled",
440
+ what: `this preview hasn't reconciled cleanly yet${review.detail ? ` — ${review.detail}` : ""}`,
441
+ next: "run `tot preview` and wait for the green reconcile/compliance result, then `tot ship`",
442
+ };
443
+ }
444
+ if (!review.authorized) {
445
+ return { kind: "unauthorized" };
446
+ }
447
+ return { kind: "ready" };
448
+ }
449
+
450
+ // ─── Diff-vs-live (pure render + thin git compute) ───────────────────────────────
451
+
452
+ /**
453
+ * Render the "what goes live" block from parsed name-status entries + a shortstat
454
+ * line. Always prints SOMETHING actionable: the changed paths when we have them,
455
+ * else a pointer to the candidate PR so the reviewer can read the full diff. Pure —
456
+ * unit-tested.
457
+ * @param {{ entries:{status:string,path:string,from?:string}[], statLine?:string,
458
+ * prUrl?:string|null, ok:boolean }} input
459
+ * @returns {string[]}
460
+ */
461
+ export function renderDiffVsLive({ entries, statLine, prUrl, ok }) {
462
+ const lines = ["", " This ship will change the LIVE site:"];
463
+ if (ok && entries.length) {
464
+ for (const e of entries.slice(0, 40)) {
465
+ const label = e.status === "R" && e.from ? `${e.from} → ${e.path}` : e.path;
466
+ lines.push(` ${statusGlyph(e.status)} ${label}`);
467
+ }
468
+ if (entries.length > 40) lines.push(` … +${entries.length - 40} more file(s)`);
469
+ if (statLine) lines.push(` (${statLine})`);
470
+ } else {
471
+ lines.push(" (couldn't render a local file diff for this candidate)");
472
+ if (prUrl) lines.push(` review the full diff in the candidate PR: ${prUrl}`);
473
+ }
474
+ return lines;
475
+ }
476
+
477
+ /** A/M/D/R → a stable one-char glyph for the diff list. Pure. */
478
+ function statusGlyph(status) {
479
+ return { A: "+", M: "~", D: "-", R: "»", C: "»" }[status] || "·";
480
+ }
481
+
482
+ /**
483
+ * Compute the diff-vs-live (base→head) via git in the checkout — best-effort. Tries
484
+ * the local objects first; if the shas aren't present it fetches once and retries;
485
+ * if it still can't, returns `ok:false` so the caller falls back to the PR pointer.
486
+ * `git` is injected (a `(args:string[])=>string` runner) so it's testable.
487
+ * @param {(args:string[]) => string} git
488
+ * @param {string|null} baseSha
489
+ * @param {string|null} headSha
490
+ * @returns {{ entries:{status:string,path:string,from?:string}[], statLine:string, ok:boolean }}
491
+ */
492
+ export function computeDiffVsLive(git, baseSha, headSha) {
493
+ if (!baseSha || !headSha) return { entries: [], statLine: "", ok: false };
494
+ const range = `${baseSha}..${headSha}`;
495
+ const tryDiff = () => ({
496
+ entries: parseNameStatus(git(["diff", "--name-status", range])),
497
+ statLine: git(["diff", "--shortstat", range]).trim(),
498
+ });
499
+ try {
500
+ return { ...tryDiff(), ok: true };
501
+ } catch {
502
+ // The shas may not be local yet (base moved, head only on the remote) — one
503
+ // fetch, then retry. Still failing ⇒ fall back to the PR pointer.
504
+ try {
505
+ git(["fetch", "--quiet", "origin"]);
506
+ return { ...tryDiff(), ok: true };
507
+ } catch {
508
+ return { entries: [], statLine: "", ok: false };
509
+ }
510
+ }
511
+ }
512
+
513
+ // ─── Ship-decision poll ──────────────────────────────────────────────────────────
514
+
515
+ /**
516
+ * Poll `change_status` until the change reports shipped (or the attempts budget
517
+ * runs out). `change_accept` transitions synchronously, so this usually resolves on
518
+ * the first read — it exists to CONFIRM the shipped state and pick up the live URL,
519
+ * and to tolerate a promote that lands a beat later. Injectable delay/attempts.
520
+ * @param {{callTool:Function}} client
521
+ * @param {{ id:string, tenant?:string }} target
522
+ * @param {{ attempts?:number, delayMs?:number, sleep?:(ms:number)=>Promise<void> }} [opts]
523
+ */
524
+ export async function pollChangeShipped(client, { id, tenant }, { attempts = 6, delayMs = 1500, sleep } = {}) {
525
+ const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
526
+ let last = null;
527
+ for (let i = 0; i < attempts; i++) {
528
+ last = normalizeChangeResult(await client.callTool("change_status", { id, ...(tenant ? { tenant } : {}) }));
529
+ if (last.shipped) return last;
530
+ if (i < attempts - 1) await wait(delayMs);
531
+ }
532
+ return last;
533
+ }
534
+
535
+ // ─── Orchestration ───────────────────────────────────────────────────────────────
536
+
537
+ /**
538
+ * The ship flow after a session is established — resolve the candidate, gate,
539
+ * diff-vs-live, confirm, accept, poll, report. Split out from `run` so it's driven
540
+ * in tests with a mock client + injected git/confirm/interactive, no network/TTY.
541
+ *
542
+ * @param {{callTool:Function}} client an MCP client (real or mock)
543
+ * @param {{ tenant:string, changeId:string, repo:string,
544
+ * git:(args:string[])=>string, noOpen?:boolean, yes?:boolean, storefrontUrl?:string|null,
545
+ * changeSummary?:{ title?:string, body?:string[] }, expectedPr?:number|string|null }} params
546
+ * @param {{ confirmPlan?:typeof printPlanAndConfirm,
547
+ * poll?:typeof pollChangeShipped, openUrl?:(u:string)=>boolean, progress?:boolean,
548
+ * resolveRecord?:typeof resolveChangeRecordId }} [deps]
549
+ * @returns {Promise<number>} process exit code
550
+ */
551
+ export async function runShip(client, { tenant, changeId, repo, git, noOpen, yes = false, storefrontUrl = null, changeSummary, expectedPr = null }, deps = {}) {
552
+ const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
553
+ const poll = deps.poll || pollChangeShipped;
554
+ const resolveRecord = deps.resolveRecord || resolveChangeRecordId;
555
+
556
+ // 1. Resolve the active candidate (the forge slice — open-gate + diff-vs-live).
557
+ const candidate = normalizeCandidate(await client.callTool("candidate_status", { repo, changeId }));
558
+
559
+ // 1a. If the developer named a PR positionally (`tot ship 4`), that number is a
560
+ // SAFETY assertion of WHICH PR ships — refuse (before opening any change record)
561
+ // if the checkout's resolved branch-bound candidate is a DIFFERENT PR, so
562
+ // `tot ship 4` can never silently ship PR #7. Only guards when both are known.
563
+ const want = expectedPr != null && `${expectedPr}`.trim() ? Number(expectedPr) : null;
564
+ if (want != null && candidate && candidate.prNumber != null && Number(candidate.prNumber) !== want) {
565
+ console.error(
566
+ fail(
567
+ `you asked to ship PR #${want}, but this checkout's active candidate is PR #${candidate.prNumber}`,
568
+ `switch to the branch whose PR is #${want} (or run \`tot ship\` with no number to ship this checkout's candidate)`,
569
+ ),
570
+ );
571
+ return 1;
572
+ }
573
+
574
+ // 1b. Resolve the change-RECORD id (`chg_<uuid>`) that the gate + change_accept key
575
+ // on — a DISTINCT namespace from the candidate handle (see the header). Only
576
+ // meaningful for an OPEN candidate; for a missing/closed one we skip it and let
577
+ // shipReadiness report that blocker first (never open a record we can't ship).
578
+ let id = null;
579
+ if (candidate && candidate.state === "open") {
580
+ try {
581
+ id = await resolveRecord(client, { tenant, git, changeSummary });
582
+ } catch (e) {
583
+ console.error(
584
+ fail(
585
+ `couldn't prepare a shipable change record: ${String(e?.message || e)}`,
586
+ "the commit you're shipping may not have reconciled — run `tot preview` and wait for the green result, then `tot ship`",
587
+ ),
588
+ );
589
+ return 1;
590
+ }
591
+ }
592
+
593
+ // 2. Read the ship-readiness verdict against the RESOLVED record (or the neutral
594
+ // review when there's no open candidate — shipReadiness reports the candidate).
595
+ const review = id
596
+ ? normalizeReview(await client.callTool("change_request_review", { id, tenant }))
597
+ : NO_REVIEW;
598
+ const gate = shipReadiness(candidate, review);
599
+
600
+ if (gate.kind === "unauthorized") {
601
+ return await handleUnauthorized(client, { id, tenant, approvers: review.approvers });
602
+ }
603
+ if (gate.kind !== "ready") {
604
+ console.error(fail(gate.what, gate.next));
605
+ return 1;
606
+ }
607
+
608
+ // 2. ALWAYS render the diff-vs-live, then print the EXACT plan and gate on ONE
609
+ // explicit confirm via the SHARED affordance (plan.mjs). DEVELOPER context =
610
+ // accept-then-deploy (merge this PR into main, THEN deploy main → preview + live).
611
+ const diff = computeDiffVsLive(git, candidate.baseSha, candidate.headSha);
612
+ for (const line of renderDiffVsLive({ ...diff, prUrl: candidate.url })) console.log(line);
613
+
614
+ const previewUrl = review.previewUrl || candidate.url || previewUrlFor(storefrontUrl, tenant, candidate.prNumber);
615
+ const planLines = planForAction({
616
+ action: "ship",
617
+ context: "developer",
618
+ tenant,
619
+ pr: candidate.prNumber,
620
+ changeId: id,
621
+ headSha: candidate.headSha,
622
+ targets: { preview: previewUrl, live: liveUrlFor(tenant) },
623
+ });
624
+ // `--yes` confirms non-interactively; a non-TTY WITHOUT `--yes` refuses (never
625
+ // auto-confirm a live change). Default answer is NO on an interactive prompt.
626
+ const { confirmed, reason } = await confirmPlan(planLines, {
627
+ yes,
628
+ question: `Ship this live to ${tenant}?`,
629
+ });
630
+ if (!confirmed) {
631
+ if (reason === "non-tty") {
632
+ console.error(
633
+ fail(
634
+ "`tot ship` needs an interactive terminal to confirm the live change",
635
+ "run it from a terminal, or pass --yes to confirm non-interactively",
636
+ ),
637
+ );
638
+ return 2;
639
+ }
640
+ console.log(" Ship cancelled — nothing changed.");
42
641
  return 0;
43
642
  }
44
643
 
644
+ // 3. Accept (the ship gate), then poll change_status until shipped.
645
+ let accept;
646
+ try {
647
+ accept = normalizeChangeResult(
648
+ await client.callTool("change_accept", {
649
+ id,
650
+ tenant,
651
+ dryRun: false,
652
+ // Stable per (candidate, head): a re-run after a blip returns the original
653
+ // ship instead of double-accepting.
654
+ idempotencyKey: `ship-${id}-${candidate.headSha ?? "head"}`,
655
+ }),
656
+ );
657
+ } catch (e) {
658
+ console.error(
659
+ fail(
660
+ `the ship gate refused to accept this change: ${String(e?.message || e)}`,
661
+ "check the reconcile/compliance result with `tot preview`, then re-run `tot ship`",
662
+ ),
663
+ );
664
+ return 1;
665
+ }
666
+
667
+ const progress = deps.progress === false ? null : startProgress("shipping…");
668
+ let status;
669
+ try {
670
+ status = await poll(client, { id, tenant });
671
+ } finally {
672
+ progress?.stop();
673
+ }
674
+ return reportShipped(accept, status, { tenant, noOpen, openUrl: deps.openUrl });
675
+ }
676
+
677
+ /**
678
+ * The unauthorised path — don't just error out. The change record was already opened
679
+ * and moved to ready_for_review while resolving the ship (`resolveChangeRecordId`), so
680
+ * the review request IS recorded; we just tell the developer that and print WHO can
681
+ * approve. (There is no separate `change_ready` to run — re-readying a ready record is
682
+ * an illegal transition; the record already sits in the reviewers' queue.)
683
+ * @returns {Promise<number>} exit code (non-zero — the ship didn't happen)
684
+ */
685
+ async function handleUnauthorized(client, { id, tenant, approvers }) {
686
+ void client;
687
+ void id;
45
688
  console.error(
46
689
  fail(
47
- "`tot ship` isn't wired up yet",
48
- "push your work for review with `tot preview`; a reviewer accepts it to ship it live " +
49
- "(change_accept / `tot pr`). Self-serve `tot ship` is coming soon.",
690
+ "you're not authorised to ship this change live yourself",
691
+ "the change is recorded and ready for review an authorised approver can ship it",
50
692
  ),
51
693
  );
52
- return 2;
694
+ if (approvers.length) {
695
+ console.error(` Who can approve: ${approvers.join(", ")}`);
696
+ } else {
697
+ console.error(` Who can approve: a teammate with ship/promote authority for ${tenant}.`);
698
+ }
699
+ return 1;
700
+ }
701
+
702
+ /** Report the shipped result and open the live URL (unless suppressed). */
703
+ function reportShipped(accept, status, { tenant, noOpen, openUrl }) {
704
+ const url = status?.previewUrl || accept?.previewUrl || null;
705
+ if (status?.shipped || accept?.shipped) {
706
+ console.log(`\n ✓ shipped ${tenant} live.`);
707
+ if (url) {
708
+ console.log(` Live: ${url}`);
709
+ if (!noOpen && openUrl && openUrl(url)) console.log(" (opened in your browser)");
710
+ }
711
+ return 0;
712
+ }
713
+ // Accept committed but the shipped state hasn't been observed yet (a promote may
714
+ // still be landing) — report honestly rather than claim a live URL.
715
+ console.log(`\n ~ ship accepted for ${tenant}; it's going live now.`);
716
+ if (url) console.log(` Track it here: ${url}`);
717
+ return 0;
718
+ }
719
+
720
+ // ─── URL derivation (pure) ─────────────────────────────────────────────────────────
721
+
722
+ /** The hosted preview URL for a tenant's PR, or null when the PR/base is unknown. Pure. */
723
+ export function previewUrlFor(base, tenant, pr) {
724
+ const origin = (base || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
725
+ if (pr == null || `${pr}`.trim() === "" || !tenant) return null;
726
+ return `${origin}/preview/${encodeURIComponent(tenant)}/pr/${pr}`;
727
+ }
728
+
729
+ /** The live site URL for a tenant (the tenant IS its apex domain, e.g. tokenoftrust.com). Pure. */
730
+ export function liveUrlFor(tenant) {
731
+ const t = (tenant || "").trim();
732
+ return t ? `https://${t}` : null;
733
+ }
734
+
735
+ // ─── Operator ship (DEPLOY-only) ─────────────────────────────────────────────────────
736
+
737
+ /**
738
+ * The OPERATOR ship flow (decision ship-context-dependent-semantics): the caller is
739
+ * targeting a `--pr N --tenant t` that ISN'T their checkout, so ship means DEPLOY
740
+ * (main → preview + live) — NOT merge. The merge is the separate `tot accept` verb.
741
+ *
742
+ * We STATE the exact deploy plan (the shared `planForAction`/`printPlanAndConfirm`
743
+ * affordance) and confirm before acting (`--yes` to confirm non-interactively; a
744
+ * non-TTY without `--yes` refuses).
745
+ *
746
+ * ┌─ CROSS-TENANT/CROSS-PR TARGETING (the app-bound-identity constraint) ────────────┐
747
+ * │ Resolving a TARGETED PR's change-record id / head sha from the forge needs a │
748
+ * │ `candidate_status` read the CLI's user identity may NOT be allowed (it's │
749
+ * │ app-bound). So we take the change-record id (and head sha) via EXPLICIT flags │
750
+ * │ (`--change-id` / `--head-sha`) rather than blocking — mirroring U3's descriptor │
751
+ * │ flags. See the `u9` marker below for the auto-resolution follow-on. │
752
+ * └───────────────────────────────────────────────────────────────────────────────────┘
753
+ *
754
+ * @param {{callTool:Function}} client an MCP client (real or mock)
755
+ * @param {{ tenant:string, pr?:number|string|null, changeId?:string|null,
756
+ * headSha?:string|null, storefrontUrl?:string|null, noOpen?:boolean, yes?:boolean }} params
757
+ * @param {{ confirmPlan?:typeof printPlanAndConfirm, poll?:typeof pollChangeShipped,
758
+ * openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
759
+ * @returns {Promise<number>} process exit code
760
+ */
761
+ export async function runShipOperator(
762
+ client,
763
+ { tenant, pr = null, changeId = null, headSha = null, storefrontUrl = null, noOpen = false, yes = false },
764
+ deps = {},
765
+ ) {
766
+ const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
767
+ const poll = deps.poll || pollChangeShipped;
768
+
769
+ // u9: auto-resolve targeted PR descriptor (changeId/headSha) via forge PR-read —
770
+ // the CLI's app-bound user identity may not be allowed the candidate_status read for
771
+ // a cross-tenant/cross-PR target, so today the operator supplies these via flags.
772
+ const previewUrl = previewUrlFor(storefrontUrl, tenant, pr);
773
+ const planLines = planForAction({
774
+ action: "ship",
775
+ context: "operator",
776
+ tenant,
777
+ pr,
778
+ changeId,
779
+ headSha,
780
+ targets: { preview: previewUrl, live: liveUrlFor(tenant) },
781
+ });
782
+ const { confirmed, reason } = await confirmPlan(planLines, {
783
+ yes,
784
+ question: `Deploy ${tenant} live now (main → preview + live)?`,
785
+ });
786
+ if (!confirmed) {
787
+ if (reason === "non-tty") {
788
+ console.error(
789
+ fail(
790
+ "`tot ship` needs an interactive terminal to confirm this deploy",
791
+ "re-run from a terminal, or pass --yes to confirm non-interactively",
792
+ ),
793
+ );
794
+ return 2;
795
+ }
796
+ console.log(" Ship cancelled — nothing deployed.");
797
+ return 0;
798
+ }
799
+
800
+ // We can't auto-resolve the targeted PR's change record from the forge (see above),
801
+ // so the operator must name it explicitly to promote it.
802
+ if (!changeId) {
803
+ console.error(
804
+ fail(
805
+ "operator ship needs the target change-record id to deploy",
806
+ "pass --change-id <chg_…> (u9 will auto-resolve it from --pr via a forge PR-read)",
807
+ ),
808
+ );
809
+ return 2;
810
+ }
811
+
812
+ // TODO(u9): operator ship is DEPLOY-only (the merge is the separate `tot accept`),
813
+ // but a DISTINCT deploy-only trigger separate from `change_accept` is NOT yet wired.
814
+ // Until it is, this uses the EXISTING promote path — `change_accept` (u7's
815
+ // promote-by-digest, keyed on the resolved change-record id) — which is the same
816
+ // mechanism the developer path calls. Adding a new deploy path is out of scope for u4.
817
+ let accept;
818
+ try {
819
+ accept = normalizeChangeResult(
820
+ await client.callTool("change_accept", {
821
+ id: changeId,
822
+ tenant,
823
+ dryRun: false,
824
+ idempotencyKey: `ship-${changeId}-${headSha ?? "head"}`,
825
+ }),
826
+ );
827
+ } catch (e) {
828
+ console.error(
829
+ fail(
830
+ `the deploy gate refused this change: ${String(e?.message || e)}`,
831
+ "check the change is merged to main and reconciled, then re-run `tot ship`",
832
+ ),
833
+ );
834
+ return 1;
835
+ }
836
+
837
+ const progress = deps.progress === false ? null : startProgress("deploying…");
838
+ let status;
839
+ try {
840
+ status = await poll(client, { id: changeId, tenant });
841
+ } finally {
842
+ progress?.stop();
843
+ }
844
+ return reportShipped(accept, status, { tenant, noOpen, openUrl: deps.openUrl });
845
+ }
846
+
847
+ /**
848
+ * The `run` wrapper for OPERATOR ship — establish a session, bind the target tenant,
849
+ * and drive `runShipOperator`. Split from `run` for the same reason the developer half
850
+ * is split from `runShip`: this half does the I/O (auth + tenant switch), the pure flow
851
+ * is unit-tested with a mock client.
852
+ * @param {ReturnType<typeof parseShipArgs>} args
853
+ * @param {any} ctx
854
+ * @param {NodeJS.ProcessEnv} env
855
+ */
856
+ async function runOperatorFlow(args, ctx, env) {
857
+ const tenant = (args.tenant || (ctx.mode === "checkout" ? ctx.tenant : null) || "").trim();
858
+ if (!tenant) {
859
+ console.error(
860
+ fail(
861
+ "operator ship needs a target tenant",
862
+ "pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com) with --pr <N>",
863
+ ),
864
+ );
865
+ return 2;
866
+ }
867
+ const pr = args.pr != null && `${args.pr}`.trim() && Number.isFinite(Number(args.pr)) ? Number(args.pr) : args.pr;
868
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
869
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
870
+ const client = createMcpClient(baseUrl);
871
+ try {
872
+ await establishSession(client, { env, prefer: args.identity || undefined });
873
+ await client.callTool("client_switch", { tenant });
874
+ return await runShipOperator(
875
+ client,
876
+ {
877
+ tenant,
878
+ pr,
879
+ changeId: args.changeId,
880
+ headSha: args.headSha,
881
+ storefrontUrl,
882
+ noOpen: args.noOpen,
883
+ yes: args.yes,
884
+ },
885
+ { openUrl: (u) => openBrowser(u) },
886
+ );
887
+ } catch (e) {
888
+ if (e instanceof AuthUnavailableError) {
889
+ console.error(fail("sign in to ship", e.hint || "run `tot login`, then re-run `tot ship`"));
890
+ return 1;
891
+ }
892
+ console.error(
893
+ fail(
894
+ `couldn't reach the ship service: ${String(e?.message || e)}`,
895
+ "check your connection and that you're signed in, then re-run",
896
+ ),
897
+ );
898
+ return 1;
899
+ }
53
900
  }
901
+
902
+ /**
903
+ * @param {string[]} argv
904
+ * @param {any} ctx
905
+ */
906
+ export async function run(argv, ctx) {
907
+ const env = process.env;
908
+ const args = parseShipArgs(argv);
909
+ if (args.help) {
910
+ console.log(USAGE);
911
+ return 0;
912
+ }
913
+
914
+ // Context-dependent (decision ship-context-dependent-semantics): targeting a
915
+ // `--pr`/`--tenant` that isn't your checkout → OPERATOR (deploy-only); otherwise,
916
+ // inside your own checkout → DEVELOPER (accept + deploy). Never guessed silently —
917
+ // both branches print the concrete plan for THAT context and confirm.
918
+ const ctxTenant = ctx.mode === "checkout" ? ctx.tenant : null;
919
+ const context = detectShipContext({
920
+ pr: args.pr,
921
+ prPositional: args.prPositional,
922
+ tenant: args.tenant,
923
+ ctxTenant,
924
+ ctxMode: ctx.mode,
925
+ });
926
+ if (context === "operator") {
927
+ return await runOperatorFlow(args, ctx, env);
928
+ }
929
+
930
+ if (ctx.mode !== "checkout") {
931
+ console.error(
932
+ fail(
933
+ "`tot ship` runs from inside a tenant checkout",
934
+ "tot clone <tenant> <dir> (then `cd` in, `tot preview`, and `tot ship`)",
935
+ ),
936
+ );
937
+ return 2;
938
+ }
939
+
940
+ const workspace = ctx.workspacePath;
941
+ const tenant = ctx.tenant;
942
+ const git = (cargs) =>
943
+ execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
944
+ const gitSafe = (cargs) => {
945
+ try {
946
+ return git(cargs);
947
+ } catch {
948
+ return "";
949
+ }
950
+ };
951
+
952
+ const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
953
+ if (!repo) {
954
+ console.error(
955
+ fail("couldn't derive the forge repo from this checkout's remote", "run this from a `tot clone`d store"),
956
+ );
957
+ return 1;
958
+ }
959
+
960
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
961
+ const statePath = defaultCandidateStatePath(env);
962
+ const client = createMcpClient(baseUrl);
963
+ try {
964
+ const session = await establishSession(client, { env, prefer: args.identity || undefined });
965
+ // Bind the active tenant so the candidate/change tools read the right scope.
966
+ await client.callTool("client_switch", { tenant });
967
+
968
+ // Resolve which candidate this checkout ships (the u4 seam) — branch-bound so
969
+ // ship promotes exactly the candidate `tot preview` pushed from THIS branch.
970
+ const changeId = resolveActiveChangeId({
971
+ statePath,
972
+ mcpUrl: baseUrl,
973
+ repo,
974
+ tenant,
975
+ actorKey: actorKeyFor(session),
976
+ branch: currentBranch(gitSafe),
977
+ });
978
+
979
+ // The title/body the change RECORD is opened with — from the HEAD commit subject
980
+ // (the reviewed commit's own message), so the approver's record isn't blank.
981
+ const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
982
+ const changeSummary = buildChangeSummary({ message: headSubject, headSubject });
983
+
984
+ return await runShip(
985
+ client,
986
+ {
987
+ tenant,
988
+ changeId,
989
+ repo,
990
+ git,
991
+ noOpen: args.noOpen,
992
+ yes: args.yes,
993
+ storefrontUrl: args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL,
994
+ changeSummary,
995
+ // A positional `tot ship 4` asserts WHICH PR — passed as a safety guard so
996
+ // ship refuses if this checkout's active candidate is a different PR.
997
+ expectedPr: args.prPositional ? args.pr : null,
998
+ },
999
+ { openUrl: (u) => openBrowser(u) },
1000
+ );
1001
+ } catch (e) {
1002
+ if (e instanceof AuthUnavailableError) {
1003
+ console.error(fail("sign in to ship", e.hint || "run `tot login`, then re-run `tot ship`"));
1004
+ return 1;
1005
+ }
1006
+ console.error(
1007
+ fail(
1008
+ `couldn't reach the ship service: ${String(e?.message || e)}`,
1009
+ "check your connection and that you're signed in, then re-run",
1010
+ ),
1011
+ );
1012
+ return 1;
1013
+ }
1014
+ }
1015
+