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

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,25 +1,36 @@
1
1
  /**
2
- * `tot ship` — promote a reconciled PREVIEW live (the deliberate 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
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
- * The contract, deliberately strict because this is the step that changes the LIVE
11
- * site:
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:
12
23
  *
13
24
  * 1. Resolve the ACTIVE candidate for this checkout (the SAME handle `tot preview`
14
25
  * last pushed to — see `resolveActiveChangeId`, the seam u4 rekeys to be
15
26
  * branch-bound) and REQUIRE it to be OPEN and RECONCILED. A merged/closed
16
27
  * candidate, or one whose evidence isn't green yet, is refused with a clear
17
28
  * 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.
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.
23
34
  * 3. On confirm, `change_accept` (the human ship gate) transitions the change to
24
35
  * shipped; we then poll `change_status` until it reports shipped and print the
25
36
  * live URL.
@@ -50,7 +61,7 @@ import { execFileSync } from "node:child_process";
50
61
  import { createMcpClient } from "../mcp.mjs";
51
62
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
52
63
  import { fail } from "../errors.mjs";
53
- import { isInteractive, promptYesNo } from "../prompt.mjs";
64
+ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
54
65
  import { startProgress } from "../progress.mjs";
55
66
  import { openBrowser } from "../open.mjs";
56
67
  import {
@@ -68,32 +79,138 @@ import {
68
79
  } from "../candidate-state.mjs";
69
80
 
70
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: 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)
71
96
 
72
- const USAGE = `tot ship promote a reconciled preview live
97
+ \`tot ship\` is CONTEXT-DEPENDENT (decision ship-context-dependent-semantics):
73
98
 
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)
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\`.
77
108
 
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.`;
109
+ Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
110
+ GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).
111
+
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.`;
116
+
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
+ }
83
130
 
84
- /** Parse `tot ship` argv. Pure. Deliberately NO --yes/--force (see the header). */
131
+ /** Parse `tot ship` argv. Pure. */
85
132
  export function parseShipArgs(argv) {
86
- const a = { mcp: null, identity: null, noOpen: false, help: false };
133
+ 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
+ tenant: null,
144
+ changeId: null,
145
+ headSha: null,
146
+ url: null,
147
+ secret: null,
148
+ help: false,
149
+ };
87
150
  for (let i = 0; i < argv.length; i++) {
88
151
  const t = argv[i];
89
152
  if (t === "--mcp") a.mcp = argv[++i];
90
153
  else if (t === "--identity") a.identity = argv[++i];
91
154
  else if (t === "--no-open") a.noOpen = true;
155
+ 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];
160
+ else if (t === "--url") a.url = argv[++i];
161
+ else if (t === "--secret") a.secret = argv[++i];
92
162
  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
+ }
93
172
  }
94
173
  return a;
95
174
  }
96
175
 
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
+ }
213
+
97
214
  // ─── Response normalisation (defensive — one MCP, but shapes may vary) ───────────
98
215
 
99
216
  /**
@@ -434,22 +551,40 @@ export async function pollChangeShipped(client, { id, tenant }, { attempts = 6,
434
551
  *
435
552
  * @param {{callTool:Function}} client an MCP client (real or mock)
436
553
  * @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>,
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,
440
557
  * poll?:typeof pollChangeShipped, openUrl?:(u:string)=>boolean, progress?:boolean,
441
- * resolveRecord?:typeof resolveChangeRecordId }} [deps]
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).
442
563
  * @returns {Promise<number>} process exit code
443
564
  */
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;
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;
447
567
  const poll = deps.poll || pollChangeShipped;
448
568
  const resolveRecord = deps.resolveRecord || resolveChangeRecordId;
449
569
 
450
570
  // 1. Resolve the active candidate (the forge slice — open-gate + diff-vs-live).
451
571
  const candidate = normalizeCandidate(await client.callTool("candidate_status", { repo, changeId }));
452
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
+
453
588
  // 1b. Resolve the change-RECORD id (`chg_<uuid>`) that the gate + change_accept key
454
589
  // on — a DISTINCT namespace from the candidate handle (see the header). Only
455
590
  // meaningful for an OPEN candidate; for a missing/closed one we skip it and let
@@ -476,6 +611,16 @@ export async function runShip(client, { tenant, changeId, repo, git, noOpen, cha
476
611
  : NO_REVIEW;
477
612
  const gate = shipReadiness(candidate, review);
478
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
+
479
624
  if (gate.kind === "unauthorized") {
480
625
  return await handleUnauthorized(client, { id, tenant, approvers: review.approvers });
481
626
  }
@@ -484,22 +629,38 @@ export async function runShip(client, { tenant, changeId, repo, git, noOpen, cha
484
629
  return 1;
485
630
  }
486
631
 
487
- // 2. ALWAYS render the diff-vs-live, then require ONE explicit y/N confirm.
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).
488
635
  const diff = computeDiffVsLive(git, candidate.baseSha, candidate.headSha);
489
636
  for (const line of renderDiffVsLive({ ...diff, prUrl: candidate.url })) console.log(line);
490
637
 
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) {
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
+ }
503
664
  console.log(" Ship cancelled — nothing changed.");
504
665
  return 0;
505
666
  }
@@ -580,6 +741,504 @@ function reportShipped(accept, status, { tenant, noOpen, openUrl }) {
580
741
  return 0;
581
742
  }
582
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
+ }
752
+
753
+ /** The live site URL for a tenant (the tenant IS its apex domain, e.g. tokenoftrust.com). Pure. */
754
+ export function liveUrlFor(tenant) {
755
+ const t = (tenant || "").trim();
756
+ return t ? `https://${t}` : null;
757
+ }
758
+
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"];
784
+
785
+ /**
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}
791
+ */
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();
797
+ }
798
+ return "";
799
+ }
800
+
801
+ /**
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.
806
+ * @param {any} data
807
+ * @returns {Array<{changeId:string, prNumber?:number|null, headSha?:string|null, previewUrl?:string|null}>}
808
+ */
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");
816
+ }
817
+
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
+ }
838
+ }
839
+ return null;
840
+ }
841
+
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
+ };
862
+ }
863
+
864
+ /**
865
+ * The OPERATOR ship-by-PR flow (unit U16). Ships ANY built PR — not just your
866
+ * checkout — with NO forge PR-read:
867
+ *
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
886
+ * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
887
+ * openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
888
+ * @returns {Promise<number>} process exit code
889
+ */
890
+ export async function runShipByPr(
891
+ { tenant, pr, secret, storefrontUrl = null, noOpen = false, yes = false },
892
+ deps = {},
893
+ ) {
894
+ const fetchImpl = deps.fetch || globalThis.fetch;
895
+ const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
896
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
897
+
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
+ if (!secret) {
909
+ console.error(
910
+ fail(
911
+ "shipping a PR that isn't your checkout is an OPERATOR action — it needs an operator secret",
912
+ "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
913
+ ),
914
+ );
915
+ return 2;
916
+ }
917
+
918
+ const authHeaders = {
919
+ authorization: `Bearer ${secret}`,
920
+ "x-tot-owner": tenant,
921
+ "x-tot-capability": "ship-on-behalf",
922
+ };
923
+
924
+ // 1. Resolve PR N → { changeId, headSha } from the tenant's OPEN candidate queue.
925
+ let queueRes;
926
+ try {
927
+ queueRes = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
928
+ } catch (e) {
929
+ console.error(
930
+ fail(`couldn't reach the candidate queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
931
+ );
932
+ return 1;
933
+ }
934
+ const queueData = await readJsonSafe(queueRes);
935
+ if (!queueRes.ok) {
936
+ const msg = queueData?.error || `HTTP ${queueRes.status}`;
937
+ console.error(
938
+ fail(
939
+ `the candidate queue refused the request: ${msg}`,
940
+ queueRes.status === 401 || queueRes.status === 403
941
+ ? "check the operator secret and that it's authorised for this tenant"
942
+ : "check --tenant / --url, then re-run",
943
+ ),
944
+ );
945
+ return 1;
946
+ }
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}`);
958
+ return 1;
959
+ }
960
+
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);
965
+ const planLines = planForAction({
966
+ action: "ship",
967
+ context: "developer",
968
+ tenant,
969
+ pr: want,
970
+ changeId: candidate.changeId,
971
+ headSha: candidate.headSha,
972
+ targets: { preview: previewUrl, live: liveUrlFor(tenant) },
973
+ });
974
+ const { confirmed, reason } = await confirmPlan(planLines, {
975
+ yes,
976
+ question: `Ship PR #${want} live to ${tenant} (merge → main, then go live)?`,
977
+ });
978
+ if (!confirmed) {
979
+ if (reason === "non-tty") {
980
+ console.error(
981
+ fail(
982
+ "`tot ship` needs an interactive terminal to confirm this live change",
983
+ "re-run from a terminal, or pass --yes to confirm non-interactively",
984
+ ),
985
+ );
986
+ return 2;
987
+ }
988
+ console.log(" Ship cancelled — nothing changed.");
989
+ return 0;
990
+ }
991
+
992
+ // 3. Accept it via the SAME endpoint the /admin Accept button uses (repo=appDomain).
993
+ const progress = deps.progress === false ? null : startProgress("shipping…");
994
+ let acceptRes;
995
+ try {
996
+ acceptRes = await fetchImpl(`${base}/api/changes/accept`, {
997
+ method: "POST",
998
+ headers: { "content-type": "application/json", ...authHeaders },
999
+ body: JSON.stringify({
1000
+ repo: tenant,
1001
+ changeId: candidate.changeId,
1002
+ ...(candidate.headSha ? { expectedHeadSha: candidate.headSha } : {}),
1003
+ }),
1004
+ });
1005
+ } catch (e) {
1006
+ progress?.stop();
1007
+ console.error(
1008
+ fail(`couldn't reach the ship endpoint at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
1009
+ );
1010
+ return 1;
1011
+ }
1012
+ const acceptData = await readJsonSafe(acceptRes);
1013
+ progress?.stop();
1014
+
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
+ }
1036
+ }
1037
+
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}.`);
1043
+ if (liveUrl) {
1044
+ console.log(` Live: ${liveUrl}`);
1045
+ if (!noOpen && openUrl && openUrl(liveUrl)) console.log(" (opened in your browser)");
1046
+ }
1047
+ return 0;
1048
+ }
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
+ );
1154
+ return 1;
1155
+ }
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
+ }
1232
+ console.error(
1233
+ 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",
1236
+ ),
1237
+ );
1238
+ return 1;
1239
+ }
1240
+ }
1241
+
583
1242
  /**
584
1243
  * @param {string[]} argv
585
1244
  * @param {any} ctx
@@ -591,6 +1250,23 @@ export async function run(argv, ctx) {
591
1250
  console.log(USAGE);
592
1251
  return 0;
593
1252
  }
1253
+
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
+
594
1270
  if (ctx.mode !== "checkout") {
595
1271
  console.error(
596
1272
  fail(
@@ -645,10 +1321,44 @@ export async function run(argv, ctx) {
645
1321
  const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
646
1322
  const changeSummary = buildChangeSummary({ message: headSubject, headSubject });
647
1323
 
1324
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
648
1325
  return await runShip(
649
1326
  client,
650
- { tenant, changeId, repo, git, noOpen: args.noOpen, changeSummary },
651
- { openUrl: (u) => openBrowser(u) },
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
+ },
652
1362
  );
653
1363
  } catch (e) {
654
1364
  if (e instanceof AuthUnavailableError) {