@tokenoftrust/cli 1.4.0-rc.1 → 1.4.0-rc.11

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.
@@ -0,0 +1,667 @@
1
+ /**
2
+ * `tot ship` — promote a reconciled PREVIEW live (the deliberate ship gate).
3
+ *
4
+ * The second half of the dev → preview → ship loop:
5
+ *
6
+ * tot dev run your store locally with save→reload
7
+ * tot preview push it to a reviewable preview (validate → reconcile → compliance)
8
+ * tot ship promote a reconciled preview live ← you are here
9
+ *
10
+ * The contract, deliberately strict because this is the step that changes the LIVE
11
+ * site:
12
+ *
13
+ * 1. Resolve the ACTIVE candidate for this checkout (the SAME handle `tot preview`
14
+ * last pushed to — see `resolveActiveChangeId`, the seam u4 rekeys to be
15
+ * branch-bound) and REQUIRE it to be OPEN and RECONCILED. A merged/closed
16
+ * candidate, or one whose evidence isn't green yet, is refused with a clear
17
+ * next step — never shipped.
18
+ * 2. ALWAYS render a diff-vs-live (what this ship changes on the live site) and
19
+ * require ONE explicit [y/N] confirm, defaulting to NO. There is deliberately
20
+ * NO `--yes` / `--force` bypass in v1: shipping live is a decision a human
21
+ * makes at the keyboard. In a NON-TTY (CI, piped) we REFUSE rather than
22
+ * auto-confirm — nothing ships without someone saying yes.
23
+ * 3. On confirm, `change_accept` (the human ship gate) transitions the change to
24
+ * shipped; we then poll `change_status` until it reports shipped and print the
25
+ * live URL.
26
+ *
27
+ * UNAUTHORISED actors don't hit a wall: if you can't approve the ship yourself we
28
+ * RECORD the approval request (`change_ready` queues it for review) and print WHO
29
+ * can approve it, so the change moves forward instead of dead-ending.
30
+ *
31
+ * WIRE (verified server-side by u7, decision ship-path-wire-resolutions):
32
+ * - A CANDIDATE and a change RECORD are DISTINCT namespaces. The candidate handle
33
+ * (`changeId`, the `candidate/<changeId>` Gitea PR) is NOT the change-record id.
34
+ * `change_request_review` / `change_ready` / `change_accept` resolve the record
35
+ * via `getChange(tenant, id)`, where `id` is the `chg_<uuid>` minted by
36
+ * `change_open` — a different id entirely.
37
+ * - So ship RESOLVES a real change-record id before the gate: `resolveChangeRecordId`
38
+ * opens a record for the exact reviewed commit (`change_open commit:<HEAD>`, so
39
+ * u7's promote-by-digest can content-address the reviewed tree — without `commit:`
40
+ * the accept fails closed `no_reviewed_commit`) and readies it against the
41
+ * authoritative reconcile evidence keyed by (tenant, commit). If that commit hasn't
42
+ * reconciled (no evidence), `change_ready` fails and we refuse with a clear next step.
43
+ * - `change_request_review` then reads the readied record (evidence green? authorised
44
+ * to promote?); `change_accept` is the commit. All key on the resolved `chg_` id.
45
+ *
46
+ * Dependency-free (global fetch via the MCP client + `git`); pure helpers are
47
+ * exported and unit-tested with a mock client, no network, no TTY.
48
+ */
49
+ import { execFileSync } from "node:child_process";
50
+ import { createMcpClient } from "../mcp.mjs";
51
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
52
+ import { fail } from "../errors.mjs";
53
+ import { isInteractive, promptYesNo } from "../prompt.mjs";
54
+ import { startProgress } from "../progress.mjs";
55
+ import { openBrowser } from "../open.mjs";
56
+ import {
57
+ repoNameFromRemote,
58
+ parseNameStatus,
59
+ deriveChangeId,
60
+ actorKeyFor,
61
+ currentBranch,
62
+ buildChangeSummary,
63
+ } from "./submit.mjs";
64
+ import {
65
+ defaultCandidateStatePath,
66
+ readActiveChangeId,
67
+ isTerminalCandidateState,
68
+ } from "../candidate-state.mjs";
69
+
70
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
71
+
72
+ const USAGE = `tot ship — promote a reconciled preview live
73
+
74
+ tot ship promote the preview you last pushed with \`tot preview\`
75
+ tot ship --identity <id> sign in as a specific identity for this ship
76
+ tot ship --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
77
+
78
+ Ship makes your preview LIVE once it has reconciled cleanly — the deliberate step
79
+ AFTER \`tot preview\`. It ALWAYS shows you the diff about to go live and asks for a
80
+ single y/N confirmation first; there is no --yes/--force, and it refuses to run
81
+ without an interactive terminal. If you can't approve the ship yourself, it records
82
+ the request and tells you who can.`;
83
+
84
+ /** Parse `tot ship` argv. Pure. Deliberately NO --yes/--force (see the header). */
85
+ export function parseShipArgs(argv) {
86
+ const a = { mcp: null, identity: null, noOpen: false, help: false };
87
+ for (let i = 0; i < argv.length; i++) {
88
+ const t = argv[i];
89
+ if (t === "--mcp") a.mcp = argv[++i];
90
+ else if (t === "--identity") a.identity = argv[++i];
91
+ else if (t === "--no-open") a.noOpen = true;
92
+ else if (t === "--help" || t === "-h") a.help = true;
93
+ }
94
+ return a;
95
+ }
96
+
97
+ // ─── Response normalisation (defensive — one MCP, but shapes may vary) ───────────
98
+
99
+ /**
100
+ * Normalise a `candidate_status` result (a single ReviewEnvironment forge slice)
101
+ * to the fields ship gates on. Returns null when there's no candidate to act on.
102
+ * Pure — unit-tested.
103
+ * @param {any} r
104
+ * @returns {{changeId?:string, state:string|null, headSha:string|null, baseSha:string|null,
105
+ * prNumber:number|null, url:string|null, mergeable:unknown}|null}
106
+ */
107
+ export function normalizeCandidate(r) {
108
+ // `tot pr` already learned candidate_status can answer as an object, a
109
+ // `{candidates:[…]}` list, or a single record — reuse that tolerance here.
110
+ const c = Array.isArray(r)
111
+ ? r[0]
112
+ : r && Array.isArray(r.candidates)
113
+ ? r.candidates[0]
114
+ : r && Array.isArray(r.environments)
115
+ ? r.environments[0]
116
+ : r;
117
+ if (!c || typeof c !== "object" || typeof c.changeId !== "string") return null;
118
+ return {
119
+ changeId: c.changeId,
120
+ state: typeof c.state === "string" ? c.state : null,
121
+ headSha: c.headSha ?? null,
122
+ baseSha: c.baseSha ?? null,
123
+ prNumber: typeof c.prNumber === "number" ? c.prNumber : null,
124
+ url: c.url ?? null,
125
+ mergeable: c.mergeable,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Normalise a `change_request_review` result to the precondition signals ship
131
+ * needs: is the evidence green (reconciled + promotable), is THIS session allowed
132
+ * to promote, and — for the unauthorised path — who can. Field names are read
133
+ * defensively (the CLI is the first caller of this tool) so a plausible rename
134
+ * degrades gracefully rather than crashing the gate. `reconciled`/`authorized` are
135
+ * TRI-STATE-ish: they're only `true` when the server clearly says so, so an
136
+ * unrecognised shape fails CLOSED (we don't ship on ambiguity). Pure — unit-tested.
137
+ * @param {any} r
138
+ * @returns {{reconciled:boolean, authorized:boolean, approvers:string[],
139
+ * previewUrl:string|null, detail:string|null, raw:any}}
140
+ */
141
+ export function normalizeReview(r) {
142
+ const o = (r && typeof r === "object" ? r : {});
143
+ const ev = o.evidence && typeof o.evidence === "object" ? o.evidence : {};
144
+ // Evidence green ⇒ reconciled + promotable. Accept the several plausible flags.
145
+ const reconciled = firstBool([
146
+ o.evidenceGreen, o.green, o.promotable, ev.green, ev.ok, ev.promotable,
147
+ o.verdict === "green" || o.verdict === "pass" ? true : undefined,
148
+ ]);
149
+ const authorized = firstBool([
150
+ o.authorized, o.authorised, o.canPromote, o.canApprove, o.authorisedToPromote,
151
+ ]);
152
+ const approvers = normalizeApprovers(
153
+ o.approvers ?? o.eligibleApprovers ?? o.whoCanApprove ?? o.promoters ?? [],
154
+ );
155
+ return {
156
+ reconciled: reconciled === true,
157
+ authorized: authorized === true,
158
+ approvers,
159
+ previewUrl: o.previewUrl ?? ev.previewUrl ?? null,
160
+ detail: typeof o.detail === "string" ? o.detail : (typeof o.reason === "string" ? o.reason : null),
161
+ raw: r,
162
+ };
163
+ }
164
+
165
+ /** First non-undefined boolean in a list, else undefined. Pure. */
166
+ function firstBool(candidates) {
167
+ for (const v of candidates) if (typeof v === "boolean") return v;
168
+ return undefined;
169
+ }
170
+
171
+ /** Coerce an approvers payload (strings, or objects with email/name) to a label list. Pure. */
172
+ export function normalizeApprovers(list) {
173
+ if (!Array.isArray(list)) return [];
174
+ return list
175
+ .map((a) => (typeof a === "string" ? a : a && (a.email || a.name || a.label || a.id)))
176
+ .filter((s) => typeof s === "string" && s.trim())
177
+ .map((s) => s.trim());
178
+ }
179
+
180
+ /**
181
+ * Normalise a `change_accept` / `change_status` result to { shipped, state,
182
+ * previewUrl, shippedAt }. `shipped` is true once the record reports the shipped
183
+ * state (or carries a shipped stamp). Pure — unit-tested.
184
+ * @param {any} r
185
+ */
186
+ export function normalizeChangeResult(r) {
187
+ const o = (r && typeof r === "object" ? r : {});
188
+ const state = typeof o.state === "string" ? o.state : null;
189
+ const shippedStamp = o.shipped && typeof o.shipped === "object" ? o.shipped : null;
190
+ const shipped = state === "shipped" || Boolean(o.shipped === true || shippedStamp);
191
+ return {
192
+ shipped,
193
+ state,
194
+ previewUrl: o.previewUrl ?? shippedStamp?.previewUrl ?? o.liveUrl ?? null,
195
+ shippedAt: shippedStamp?.shippedAt ?? o.shippedAt ?? null,
196
+ raw: r,
197
+ };
198
+ }
199
+
200
+ // ─── The candidate-resolution seam (u4 extends THIS) ─────────────────────────────
201
+
202
+ /**
203
+ * Resolve the ACTIVE candidate's changeId for this checkout — the SAME resolution
204
+ * `tot preview` uses so ship promotes exactly what preview last pushed: the
205
+ * remembered active pointer (a prior `--new` / roll), else the STABLE
206
+ * per-developer-per-tenant default (`deriveChangeId`).
207
+ *
208
+ * ┌─ SEAM (u4) ────────────────────────────────────────────────────────────────┐
209
+ * │ This is the ONE place ship keys the candidate on (tenant, actor, branch). │
210
+ * │ u4 (branch-bound candidates) keys HERE — folding in the current git branch — │
211
+ * │ so `tot preview` and `tot ship` resolve the SAME branch-bound candidate, │
212
+ * │ WITHOUT touching the gate, diff, confirm, or accept flow below. On the │
213
+ * │ default branch `branch` is null and this is byte-identical to pre-u4. │
214
+ * └──────────────────────────────────────────────────────────────────────────────┘
215
+ * Pure given its inputs (state is read through candidate-state.mjs). Exported so a
216
+ * follow-on unit can wrap/replace it.
217
+ * @param {{ statePath:string, mcpUrl:string, repo:string, tenant:string, actorKey:string,
218
+ * branch?:string|null }} keys
219
+ * @returns {string}
220
+ */
221
+ export function resolveActiveChangeId({ statePath, mcpUrl, repo, tenant, actorKey, branch = null }) {
222
+ const stable = deriveChangeId(tenant, actorKey, branch);
223
+ const active = readActiveChangeId(statePath, { mcpUrl, repo, branch });
224
+ return active || stable;
225
+ }
226
+
227
+ /**
228
+ * Normalise a `change_open` result to its minted change-record id. `change_open`
229
+ * returns the record id flat in `data` (id, state); read it defensively. Pure.
230
+ * @param {any} r
231
+ * @returns {{ id: string|null, state: string|null }}
232
+ */
233
+ export function normalizeOpenedChange(r) {
234
+ const o = r && typeof r === "object" ? r : {};
235
+ return {
236
+ id: typeof o.id === "string" && o.id ? o.id : null,
237
+ state: typeof o.state === "string" ? o.state : null,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Resolve the change-RECORD id (`chg_<uuid>`) that the ship gate + `change_accept`
243
+ * key on — a DISTINCT namespace from the candidate handle (see the module header).
244
+ * There is NO candidate→record lookup on the wire (`change_status` lists only
245
+ * id/title/state, never the head sha), so we CAPTURE the id at `change_open`, the
246
+ * option u7's decision (ship-path-wire-resolutions) records as supported:
247
+ *
248
+ * 1. read the reviewed commit — the local HEAD, the commit `tot preview` pushed and
249
+ * the reconcile evidence is keyed on;
250
+ * 2. `change_open` it with `commit:` set, so u7's promote-by-digest can content-
251
+ * address the exact reviewed tree (absent it, `change_accept` fails closed
252
+ * `no_reviewed_commit`);
253
+ * 3. `change_ready` it, attaching the AUTHORITATIVE reconcile evidence keyed by
254
+ * (tenant, commit). This REQUIRES that commit to have reconciled — if it hasn't,
255
+ * `change_ready` throws (no authoritative evidence) and we propagate it so the
256
+ * caller refuses with a "run `tot preview` first" next step (never ship a commit
257
+ * whose evidence isn't in).
258
+ *
259
+ * Returns the resolved `chg_` id. Throws if the record can't be opened/readied — the
260
+ * caller fails CLOSED (never falls back to shipping the candidate handle or no record).
261
+ * The `change_*` calls go through the same client; `git` is injected.
262
+ * @param {{callTool:Function}} client
263
+ * @param {{ tenant:string, git:(args:string[])=>string,
264
+ * changeSummary?:{ title?:string, body?:string[] } }} params
265
+ * @returns {Promise<string>}
266
+ */
267
+ export async function resolveChangeRecordId(client, { tenant, git, changeSummary }) {
268
+ const commit = git(["rev-parse", "HEAD"]).trim();
269
+ if (!commit) {
270
+ throw new Error("couldn't read the reviewed commit (git HEAD) for this checkout");
271
+ }
272
+ const title = (changeSummary?.title || `Ship ${tenant} store change`).slice(0, 200);
273
+ const body = changeSummary?.body?.length ? changeSummary.body.join("\n").slice(0, 2000) : undefined;
274
+ const opened = normalizeOpenedChange(
275
+ await client.callTool("change_open", {
276
+ title,
277
+ ...(body ? { summary: body } : {}),
278
+ commit,
279
+ tenant,
280
+ dryRun: false,
281
+ }),
282
+ );
283
+ if (!opened.id) {
284
+ throw new Error("change_open did not return a change-record id");
285
+ }
286
+ // Ready it against the authoritative reconcile evidence for THIS exact commit —
287
+ // throws (no_authoritative_evidence) if the commit hasn't reconciled yet.
288
+ await client.callTool("change_ready", { id: opened.id, tenant, dryRun: false });
289
+ return opened.id;
290
+ }
291
+
292
+ /** A neutral review used only when there's no open candidate to resolve a record for
293
+ * — shipReadiness reports the candidate blocker first and never reads these. */
294
+ export const NO_REVIEW = Object.freeze({
295
+ reconciled: false, authorized: false, approvers: [], previewUrl: null, detail: null, raw: null,
296
+ });
297
+
298
+ // ─── The ship gate (pure) ────────────────────────────────────────────────────────
299
+
300
+ /**
301
+ * Decide, from the resolved candidate + the review verdict, whether the ship may
302
+ * proceed — the correctness-critical core, kept PURE so every branch is unit-tested
303
+ * without a client/TTY/git. Order matters: OPEN, then RECONCILED, then AUTHORISED,
304
+ * so the developer always sees the most fundamental blocker first.
305
+ *
306
+ * @param {ReturnType<typeof normalizeCandidate>} candidate
307
+ * @param {ReturnType<typeof normalizeReview>} review
308
+ * @returns {{ kind: "no-candidate"|"not-open"|"not-reconciled"|"unauthorized"|"ready",
309
+ * what?: string, next?: string }}
310
+ */
311
+ export function shipReadiness(candidate, review) {
312
+ if (!candidate) {
313
+ return {
314
+ kind: "no-candidate",
315
+ what: "no active candidate to ship for this checkout",
316
+ next: "push one first with `tot preview`, then `tot ship`",
317
+ };
318
+ }
319
+ if (candidate.state !== "open") {
320
+ // Terminal (merged/closed) vs simply-not-open both mean "nothing OPEN to ship".
321
+ const terminal = isTerminalCandidateState(candidate.state);
322
+ return {
323
+ kind: "not-open",
324
+ what: terminal
325
+ ? `this candidate is already ${candidate.state} — there's nothing open to ship`
326
+ : `this candidate isn't open (state: ${candidate.state ?? "unknown"})`,
327
+ next: "`tot preview` to push a fresh candidate, then `tot ship`",
328
+ };
329
+ }
330
+ if (!review.reconciled) {
331
+ return {
332
+ kind: "not-reconciled",
333
+ what: `this preview hasn't reconciled cleanly yet${review.detail ? ` — ${review.detail}` : ""}`,
334
+ next: "run `tot preview` and wait for the green reconcile/compliance result, then `tot ship`",
335
+ };
336
+ }
337
+ if (!review.authorized) {
338
+ return { kind: "unauthorized" };
339
+ }
340
+ return { kind: "ready" };
341
+ }
342
+
343
+ // ─── Diff-vs-live (pure render + thin git compute) ───────────────────────────────
344
+
345
+ /**
346
+ * Render the "what goes live" block from parsed name-status entries + a shortstat
347
+ * line. Always prints SOMETHING actionable: the changed paths when we have them,
348
+ * else a pointer to the candidate PR so the reviewer can read the full diff. Pure —
349
+ * unit-tested.
350
+ * @param {{ entries:{status:string,path:string,from?:string}[], statLine?:string,
351
+ * prUrl?:string|null, ok:boolean }} input
352
+ * @returns {string[]}
353
+ */
354
+ export function renderDiffVsLive({ entries, statLine, prUrl, ok }) {
355
+ const lines = ["", " This ship will change the LIVE site:"];
356
+ if (ok && entries.length) {
357
+ for (const e of entries.slice(0, 40)) {
358
+ const label = e.status === "R" && e.from ? `${e.from} → ${e.path}` : e.path;
359
+ lines.push(` ${statusGlyph(e.status)} ${label}`);
360
+ }
361
+ if (entries.length > 40) lines.push(` … +${entries.length - 40} more file(s)`);
362
+ if (statLine) lines.push(` (${statLine})`);
363
+ } else {
364
+ lines.push(" (couldn't render a local file diff for this candidate)");
365
+ if (prUrl) lines.push(` review the full diff in the candidate PR: ${prUrl}`);
366
+ }
367
+ return lines;
368
+ }
369
+
370
+ /** A/M/D/R → a stable one-char glyph for the diff list. Pure. */
371
+ function statusGlyph(status) {
372
+ return { A: "+", M: "~", D: "-", R: "»", C: "»" }[status] || "·";
373
+ }
374
+
375
+ /**
376
+ * Compute the diff-vs-live (base→head) via git in the checkout — best-effort. Tries
377
+ * the local objects first; if the shas aren't present it fetches once and retries;
378
+ * if it still can't, returns `ok:false` so the caller falls back to the PR pointer.
379
+ * `git` is injected (a `(args:string[])=>string` runner) so it's testable.
380
+ * @param {(args:string[]) => string} git
381
+ * @param {string|null} baseSha
382
+ * @param {string|null} headSha
383
+ * @returns {{ entries:{status:string,path:string,from?:string}[], statLine:string, ok:boolean }}
384
+ */
385
+ export function computeDiffVsLive(git, baseSha, headSha) {
386
+ if (!baseSha || !headSha) return { entries: [], statLine: "", ok: false };
387
+ const range = `${baseSha}..${headSha}`;
388
+ const tryDiff = () => ({
389
+ entries: parseNameStatus(git(["diff", "--name-status", range])),
390
+ statLine: git(["diff", "--shortstat", range]).trim(),
391
+ });
392
+ try {
393
+ return { ...tryDiff(), ok: true };
394
+ } catch {
395
+ // The shas may not be local yet (base moved, head only on the remote) — one
396
+ // fetch, then retry. Still failing ⇒ fall back to the PR pointer.
397
+ try {
398
+ git(["fetch", "--quiet", "origin"]);
399
+ return { ...tryDiff(), ok: true };
400
+ } catch {
401
+ return { entries: [], statLine: "", ok: false };
402
+ }
403
+ }
404
+ }
405
+
406
+ // ─── Ship-decision poll ──────────────────────────────────────────────────────────
407
+
408
+ /**
409
+ * Poll `change_status` until the change reports shipped (or the attempts budget
410
+ * runs out). `change_accept` transitions synchronously, so this usually resolves on
411
+ * the first read — it exists to CONFIRM the shipped state and pick up the live URL,
412
+ * and to tolerate a promote that lands a beat later. Injectable delay/attempts.
413
+ * @param {{callTool:Function}} client
414
+ * @param {{ id:string, tenant?:string }} target
415
+ * @param {{ attempts?:number, delayMs?:number, sleep?:(ms:number)=>Promise<void> }} [opts]
416
+ */
417
+ export async function pollChangeShipped(client, { id, tenant }, { attempts = 6, delayMs = 1500, sleep } = {}) {
418
+ const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
419
+ let last = null;
420
+ for (let i = 0; i < attempts; i++) {
421
+ last = normalizeChangeResult(await client.callTool("change_status", { id, ...(tenant ? { tenant } : {}) }));
422
+ if (last.shipped) return last;
423
+ if (i < attempts - 1) await wait(delayMs);
424
+ }
425
+ return last;
426
+ }
427
+
428
+ // ─── Orchestration ───────────────────────────────────────────────────────────────
429
+
430
+ /**
431
+ * The ship flow after a session is established — resolve the candidate, gate,
432
+ * diff-vs-live, confirm, accept, poll, report. Split out from `run` so it's driven
433
+ * in tests with a mock client + injected git/confirm/interactive, no network/TTY.
434
+ *
435
+ * @param {{callTool:Function}} client an MCP client (real or mock)
436
+ * @param {{ tenant:string, changeId:string, repo:string,
437
+ * git:(args:string[])=>string, noOpen?:boolean,
438
+ * changeSummary?:{ title?:string, body?:string[] } }} params
439
+ * @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
440
+ * poll?:typeof pollChangeShipped, openUrl?:(u:string)=>boolean, progress?:boolean,
441
+ * resolveRecord?:typeof resolveChangeRecordId }} [deps]
442
+ * @returns {Promise<number>} process exit code
443
+ */
444
+ export async function runShip(client, { tenant, changeId, repo, git, noOpen, changeSummary }, deps = {}) {
445
+ const interactive = deps.interactive || isInteractive;
446
+ const confirm = deps.confirm || promptYesNo;
447
+ const poll = deps.poll || pollChangeShipped;
448
+ const resolveRecord = deps.resolveRecord || resolveChangeRecordId;
449
+
450
+ // 1. Resolve the active candidate (the forge slice — open-gate + diff-vs-live).
451
+ const candidate = normalizeCandidate(await client.callTool("candidate_status", { repo, changeId }));
452
+
453
+ // 1b. Resolve the change-RECORD id (`chg_<uuid>`) that the gate + change_accept key
454
+ // on — a DISTINCT namespace from the candidate handle (see the header). Only
455
+ // meaningful for an OPEN candidate; for a missing/closed one we skip it and let
456
+ // shipReadiness report that blocker first (never open a record we can't ship).
457
+ let id = null;
458
+ if (candidate && candidate.state === "open") {
459
+ try {
460
+ id = await resolveRecord(client, { tenant, git, changeSummary });
461
+ } catch (e) {
462
+ console.error(
463
+ fail(
464
+ `couldn't prepare a shipable change record: ${String(e?.message || e)}`,
465
+ "the commit you're shipping may not have reconciled — run `tot preview` and wait for the green result, then `tot ship`",
466
+ ),
467
+ );
468
+ return 1;
469
+ }
470
+ }
471
+
472
+ // 2. Read the ship-readiness verdict against the RESOLVED record (or the neutral
473
+ // review when there's no open candidate — shipReadiness reports the candidate).
474
+ const review = id
475
+ ? normalizeReview(await client.callTool("change_request_review", { id, tenant }))
476
+ : NO_REVIEW;
477
+ const gate = shipReadiness(candidate, review);
478
+
479
+ if (gate.kind === "unauthorized") {
480
+ return await handleUnauthorized(client, { id, tenant, approvers: review.approvers });
481
+ }
482
+ if (gate.kind !== "ready") {
483
+ console.error(fail(gate.what, gate.next));
484
+ return 1;
485
+ }
486
+
487
+ // 2. ALWAYS render the diff-vs-live, then require ONE explicit y/N confirm.
488
+ const diff = computeDiffVsLive(git, candidate.baseSha, candidate.headSha);
489
+ for (const line of renderDiffVsLive({ ...diff, prUrl: candidate.url })) console.log(line);
490
+
491
+ // NON-TTY: refuse rather than auto-confirm — nothing ships without a human yes.
492
+ if (!interactive()) {
493
+ console.error(
494
+ fail(
495
+ "`tot ship` needs an interactive terminal to confirm the live change",
496
+ "run it from a terminal (there is intentionally no --yes/--force)",
497
+ ),
498
+ );
499
+ return 2;
500
+ }
501
+ const proceed = await confirm(`\n Ship this live to ${tenant}?`, false);
502
+ if (!proceed) {
503
+ console.log(" Ship cancelled — nothing changed.");
504
+ return 0;
505
+ }
506
+
507
+ // 3. Accept (the ship gate), then poll change_status until shipped.
508
+ let accept;
509
+ try {
510
+ accept = normalizeChangeResult(
511
+ await client.callTool("change_accept", {
512
+ id,
513
+ tenant,
514
+ dryRun: false,
515
+ // Stable per (candidate, head): a re-run after a blip returns the original
516
+ // ship instead of double-accepting.
517
+ idempotencyKey: `ship-${id}-${candidate.headSha ?? "head"}`,
518
+ }),
519
+ );
520
+ } catch (e) {
521
+ console.error(
522
+ fail(
523
+ `the ship gate refused to accept this change: ${String(e?.message || e)}`,
524
+ "check the reconcile/compliance result with `tot preview`, then re-run `tot ship`",
525
+ ),
526
+ );
527
+ return 1;
528
+ }
529
+
530
+ const progress = deps.progress === false ? null : startProgress("shipping…");
531
+ let status;
532
+ try {
533
+ status = await poll(client, { id, tenant });
534
+ } finally {
535
+ progress?.stop();
536
+ }
537
+ return reportShipped(accept, status, { tenant, noOpen, openUrl: deps.openUrl });
538
+ }
539
+
540
+ /**
541
+ * The unauthorised path — don't just error out. The change record was already opened
542
+ * and moved to ready_for_review while resolving the ship (`resolveChangeRecordId`), so
543
+ * the review request IS recorded; we just tell the developer that and print WHO can
544
+ * approve. (There is no separate `change_ready` to run — re-readying a ready record is
545
+ * an illegal transition; the record already sits in the reviewers' queue.)
546
+ * @returns {Promise<number>} exit code (non-zero — the ship didn't happen)
547
+ */
548
+ async function handleUnauthorized(client, { id, tenant, approvers }) {
549
+ void client;
550
+ void id;
551
+ console.error(
552
+ fail(
553
+ "you're not authorised to ship this change live yourself",
554
+ "the change is recorded and ready for review — an authorised approver can ship it",
555
+ ),
556
+ );
557
+ if (approvers.length) {
558
+ console.error(` Who can approve: ${approvers.join(", ")}`);
559
+ } else {
560
+ console.error(` Who can approve: a teammate with ship/promote authority for ${tenant}.`);
561
+ }
562
+ return 1;
563
+ }
564
+
565
+ /** Report the shipped result and open the live URL (unless suppressed). */
566
+ function reportShipped(accept, status, { tenant, noOpen, openUrl }) {
567
+ const url = status?.previewUrl || accept?.previewUrl || null;
568
+ if (status?.shipped || accept?.shipped) {
569
+ console.log(`\n ✓ shipped ${tenant} live.`);
570
+ if (url) {
571
+ console.log(` Live: ${url}`);
572
+ if (!noOpen && openUrl && openUrl(url)) console.log(" (opened in your browser)");
573
+ }
574
+ return 0;
575
+ }
576
+ // Accept committed but the shipped state hasn't been observed yet (a promote may
577
+ // still be landing) — report honestly rather than claim a live URL.
578
+ console.log(`\n ~ ship accepted for ${tenant}; it's going live now.`);
579
+ if (url) console.log(` Track it here: ${url}`);
580
+ return 0;
581
+ }
582
+
583
+ /**
584
+ * @param {string[]} argv
585
+ * @param {any} ctx
586
+ */
587
+ export async function run(argv, ctx) {
588
+ const env = process.env;
589
+ const args = parseShipArgs(argv);
590
+ if (args.help) {
591
+ console.log(USAGE);
592
+ return 0;
593
+ }
594
+ if (ctx.mode !== "checkout") {
595
+ console.error(
596
+ fail(
597
+ "`tot ship` runs from inside a tenant checkout",
598
+ "tot clone <tenant> <dir> (then `cd` in, `tot preview`, and `tot ship`)",
599
+ ),
600
+ );
601
+ return 2;
602
+ }
603
+
604
+ const workspace = ctx.workspacePath;
605
+ const tenant = ctx.tenant;
606
+ const git = (cargs) =>
607
+ execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
608
+ const gitSafe = (cargs) => {
609
+ try {
610
+ return git(cargs);
611
+ } catch {
612
+ return "";
613
+ }
614
+ };
615
+
616
+ const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
617
+ if (!repo) {
618
+ console.error(
619
+ fail("couldn't derive the forge repo from this checkout's remote", "run this from a `tot clone`d store"),
620
+ );
621
+ return 1;
622
+ }
623
+
624
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
625
+ const statePath = defaultCandidateStatePath(env);
626
+ const client = createMcpClient(baseUrl);
627
+ try {
628
+ const session = await establishSession(client, { env, prefer: args.identity || undefined });
629
+ // Bind the active tenant so the candidate/change tools read the right scope.
630
+ await client.callTool("client_switch", { tenant });
631
+
632
+ // Resolve which candidate this checkout ships (the u4 seam) — branch-bound so
633
+ // ship promotes exactly the candidate `tot preview` pushed from THIS branch.
634
+ const changeId = resolveActiveChangeId({
635
+ statePath,
636
+ mcpUrl: baseUrl,
637
+ repo,
638
+ tenant,
639
+ actorKey: actorKeyFor(session),
640
+ branch: currentBranch(gitSafe),
641
+ });
642
+
643
+ // The title/body the change RECORD is opened with — from the HEAD commit subject
644
+ // (the reviewed commit's own message), so the approver's record isn't blank.
645
+ const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
646
+ const changeSummary = buildChangeSummary({ message: headSubject, headSubject });
647
+
648
+ return await runShip(
649
+ client,
650
+ { tenant, changeId, repo, git, noOpen: args.noOpen, changeSummary },
651
+ { openUrl: (u) => openBrowser(u) },
652
+ );
653
+ } catch (e) {
654
+ if (e instanceof AuthUnavailableError) {
655
+ console.error(fail("sign in to ship", e.hint || "run `tot login`, then re-run `tot ship`"));
656
+ return 1;
657
+ }
658
+ console.error(
659
+ fail(
660
+ `couldn't reach the ship service: ${String(e?.message || e)}`,
661
+ "check your connection and that you're signed in, then re-run",
662
+ ),
663
+ );
664
+ return 1;
665
+ }
666
+ }
667
+