@tokenoftrust/cli 1.4.0-rc.11 → 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,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,128 @@ 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)
71
85
 
72
- const USAGE = `tot ship promote a reconciled preview live
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)
73
94
 
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)
95
+ \`tot ship\` is CONTEXT-DEPENDENT (decision ship-context-dependent-semantics):
77
96
 
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.`;
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).
83
103
 
84
- /** Parse `tot ship` argv. Pure. Deliberately NO --yes/--force (see the header). */
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.`;
108
+
109
+ /**
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}
116
+ */
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. */
85
124
  export function parseShipArgs(argv) {
86
- const a = { mcp: null, identity: null, noOpen: false, help: false };
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
+ };
87
141
  for (let i = 0; i < argv.length; i++) {
88
142
  const t = argv[i];
89
143
  if (t === "--mcp") a.mcp = argv[++i];
90
144
  else if (t === "--identity") a.identity = argv[++i];
91
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];
92
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
+ }
93
162
  }
94
163
  return a;
95
164
  }
96
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
+
97
204
  // ─── Response normalisation (defensive — one MCP, but shapes may vary) ───────────
98
205
 
99
206
  /**
@@ -434,22 +541,36 @@ export async function pollChangeShipped(client, { id, tenant }, { attempts = 6,
434
541
  *
435
542
  * @param {{callTool:Function}} client an MCP client (real or mock)
436
543
  * @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>,
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,
440
547
  * poll?:typeof pollChangeShipped, openUrl?:(u:string)=>boolean, progress?:boolean,
441
548
  * resolveRecord?:typeof resolveChangeRecordId }} [deps]
442
549
  * @returns {Promise<number>} process exit code
443
550
  */
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;
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;
447
553
  const poll = deps.poll || pollChangeShipped;
448
554
  const resolveRecord = deps.resolveRecord || resolveChangeRecordId;
449
555
 
450
556
  // 1. Resolve the active candidate (the forge slice — open-gate + diff-vs-live).
451
557
  const candidate = normalizeCandidate(await client.callTool("candidate_status", { repo, changeId }));
452
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
+
453
574
  // 1b. Resolve the change-RECORD id (`chg_<uuid>`) that the gate + change_accept key
454
575
  // on — a DISTINCT namespace from the candidate handle (see the header). Only
455
576
  // meaningful for an OPEN candidate; for a missing/closed one we skip it and let
@@ -484,22 +605,38 @@ export async function runShip(client, { tenant, changeId, repo, git, noOpen, cha
484
605
  return 1;
485
606
  }
486
607
 
487
- // 2. ALWAYS render the diff-vs-live, then require ONE explicit y/N confirm.
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).
488
611
  const diff = computeDiffVsLive(git, candidate.baseSha, candidate.headSha);
489
612
  for (const line of renderDiffVsLive({ ...diff, prUrl: candidate.url })) console.log(line);
490
613
 
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) {
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
+ }
503
640
  console.log(" Ship cancelled — nothing changed.");
504
641
  return 0;
505
642
  }
@@ -580,6 +717,188 @@ function reportShipped(accept, status, { tenant, noOpen, openUrl }) {
580
717
  return 0;
581
718
  }
582
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
+ }
900
+ }
901
+
583
902
  /**
584
903
  * @param {string[]} argv
585
904
  * @param {any} ctx
@@ -591,6 +910,23 @@ export async function run(argv, ctx) {
591
910
  console.log(USAGE);
592
911
  return 0;
593
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
+
594
930
  if (ctx.mode !== "checkout") {
595
931
  console.error(
596
932
  fail(
@@ -647,7 +983,19 @@ export async function run(argv, ctx) {
647
983
 
648
984
  return await runShip(
649
985
  client,
650
- { tenant, changeId, repo, git, noOpen: args.noOpen, changeSummary },
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
+ },
651
999
  { openUrl: (u) => openBrowser(u) },
652
1000
  );
653
1001
  } catch (e) {
@@ -923,6 +923,38 @@ function reportComplianceCheck(c) {
923
923
  if (c.hint) console.log(` → fix: ${c.hint}`);
924
924
  }
925
925
 
926
+ /**
927
+ * Build the printed lines for the headline "share this with your reviewer" block —
928
+ * the whole point of U14: on a successful preview, the SHAREABLE deep link
929
+ * (`https://storefront.tokenoftrust.store/preview/<tenant>/pr/<N>`, built server-side
930
+ * by the reconcile report and threaded through as `previewUrl`) is what a developer
931
+ * hands to a teammate, NOT the internal Gitea PR url `reportCandidate` prints earlier
932
+ * in the flow (step 2b) — that one stays as-is, secondary, for the developer's own
933
+ * reference. Labeled prominently + paired with an honest auth caveat: the share
934
+ * target is a TEAMMATE WITH STORE ACCESS (member/staff of the tenant), never a
935
+ * public/anonymous link.
936
+ *
937
+ * Degrades gracefully when `previewUrl` isn't (yet) on the status result — an older
938
+ * MCP, or a reconcile that hasn't finished minting it — with a note instead of a
939
+ * crash or a silent blank. Pure — unit-tested.
940
+ * @param {{ previewUrl?: string|null, status?: string }|null} s
941
+ * @param {string} tenant
942
+ * @returns {string[]}
943
+ */
944
+ export function formatShareableUrlBlock(s, tenant) {
945
+ if (s?.previewUrl) {
946
+ return [
947
+ `\n ✓ Preview ready — share this with your reviewer:`,
948
+ ` ${s.previewUrl}`,
949
+ ` ℹ your reviewer needs store access (a member/staff of ${tenant}) to view it — it's not a public link.`,
950
+ ];
951
+ }
952
+ if (s?.status === "reconciled") {
953
+ return [`\n ~ reconciled, but no shareable preview URL yet — it'll show up here once available.`];
954
+ }
955
+ return [];
956
+ }
957
+
926
958
  /**
927
959
  * Print the reconcile/compliance/preview result and, on a clean reconcile with a
928
960
  * preview URL, open it in the browser (unless opts.open === false).
@@ -956,10 +988,8 @@ function reportStatus(s, tenant, { open = true } = {}) {
956
988
  } else if (s.status === "reconciled") {
957
989
  console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
958
990
  }
959
- if (s.previewUrl) {
960
- console.log(`\n Preview: ${s.previewUrl}`);
961
- if (open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
962
- console.log(" (opened in your browser)");
963
- }
991
+ for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
992
+ if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
993
+ console.log(" (opened in your browser)");
964
994
  }
965
995
  }