@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.20

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,517 @@
1
+ /**
2
+ * `tot ship` — publish the tenant's CURRENT GREEN AGGREGATE to live. ONE
3
+ * meaning (unit b10 — SUPERSEDES the retired context-dependent ship, decision
4
+ * `ship-context-dependent-semantics`, and its developer-accept-then-deploy /
5
+ * operator-deploy-only / ship-any-built-PR branches):
6
+ *
7
+ * tot dev run your store locally with save→reload
8
+ * tot preview push it to a reviewable preview (validate → reconcile → compliance)
9
+ * tot ship publish the current green aggregate live ← you are here
10
+ *
11
+ * `tot ship` NEVER takes a PR/candidate target and NEVER infers context from
12
+ * your checkout or who's running it. There is nothing to detect: it ships the
13
+ * tenant's shared `preview` aggregate — the batch of PRs that integrated
14
+ * cleanly and went green (b07's queue) — full stop. The candidate-level merge
15
+ * is the separate `tot accept` verb (`accept.mjs`); ship never merges.
16
+ *
17
+ * THE FLOW:
18
+ *
19
+ * 1. GET the read-only PLAN from `/api/changes/ship` — b09's
20
+ * `AggregateShipOrchestrator.plan()` exposed over HTTP: the pinned
21
+ * aggregate sha, its content-addressed artifact digest, every included
22
+ * PR, the rollback target (what a rollback would restore), and the
23
+ * go-live paywall verdict. Zero side effects.
24
+ * 2. Print the EXACT plan via the shared U10 affordance (`../plan.mjs`) and
25
+ * require ONE explicit confirm, defaulting to NO. `--yes` confirms
26
+ * non-interactively; a non-TTY WITHOUT `--yes` REFUSES — nothing ships
27
+ * without an explicit yes.
28
+ * 3. On confirm, POST `/api/changes/ship` with `confirmGoLive: true` (the
29
+ * human go-live gate) and the reviewed `expectedAggregateSha` +
30
+ * `expectedArtifactDigest` — so a STALE plan (the aggregate moved, or the
31
+ * artifact changed, between GET and POST) is refused rather than shipping
32
+ * something nobody reviewed.
33
+ * 4. Report the orchestrator's HONEST terminal state verbatim:
34
+ * `shipped` (the live channel is VERIFIED serving the pinned artifact —
35
+ * the only state ever rendered as "shipped live"), `refused` (a gate
36
+ * refused before any side effect — not green, paywalled, etc.),
37
+ * `promote_failed` / `record_failed` (a partial failure, recoverable by
38
+ * re-running `tot ship`, never a false live claim).
39
+ *
40
+ * TRANSPORT: the CLI holds no storefront cookie (its OAuth login is with the
41
+ * MCP, a different trust boundary), so `/api/changes/ship` is reached with a
42
+ * Bearer OPERATOR SECRET + `X-Tot-Owner` — the SAME transport the retired
43
+ * `tot ship <N> --tenant` operator-by-PR path used. No MCP client, no git: ship
44
+ * is now pure HTTP (dependency-free — global `fetch` only).
45
+ */
46
+ import { fail } from "../errors.mjs";
47
+ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
48
+ import { startProgress } from "../progress.mjs";
49
+ import { openBrowser } from "../open.mjs";
50
+ import { emitActivity } from "../activity.mjs";
51
+
52
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
53
+
54
+ const USAGE = `tot ship — publish the tenant's CURRENT GREEN AGGREGATE to live (ONE meaning)
55
+
56
+ tot ship ship the checkout tenant's current green aggregate
57
+ tot ship --tenant <t> ship a named tenant's current green aggregate (no checkout needed)
58
+ tot ship --yes confirm non-interactively (skip the [y/N] prompt)
59
+ tot ship --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
60
+ tot ship --secret <s> operator secret (prefer the env vars below)
61
+ tot ship --no-open don't open the live URL in your browser
62
+
63
+ Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
64
+ GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).
65
+
66
+ \`tot ship\` has ONE meaning: it publishes the tenant's shared preview
67
+ aggregate — the batch of PRs that integrated cleanly and went GREEN — to
68
+ live. It NEVER takes a PR/candidate target and never infers anything from
69
+ your checkout or who you are. It shows you the EXACT plan first (the pinned
70
+ sha, the artifact digest, every included PR, the rollback target, and the
71
+ go-live paywall verdict) and waits for one explicit confirm (\`--yes\` to
72
+ skip the prompt; a non-TTY without \`--yes\` refuses). Ship then reports
73
+ VERIFIED live truth — never a false "shipped" — surfacing a refusal or a
74
+ recoverable partial failure honestly if it isn't.
75
+
76
+ The candidate-level merge is the separate \`tot accept\` / \`tot merge\` verb —
77
+ \`tot ship\` never merges.`;
78
+
79
+ /** Parse `tot ship` argv. Pure. */
80
+ export function parseShipArgs(argv) {
81
+ const a = {
82
+ tenant: null,
83
+ yes: false,
84
+ noOpen: false,
85
+ url: null,
86
+ secret: null,
87
+ help: false,
88
+ };
89
+ for (let i = 0; i < argv.length; i++) {
90
+ const t = argv[i];
91
+ if (t === "--tenant") a.tenant = argv[++i];
92
+ else if (t === "--yes" || t === "-y") a.yes = true;
93
+ else if (t === "--no-open") a.noOpen = true;
94
+ else if (t === "--url") a.url = argv[++i];
95
+ else if (t === "--secret") a.secret = argv[++i];
96
+ else if (t === "--help" || t === "-h") a.help = true;
97
+ }
98
+ return a;
99
+ }
100
+
101
+ // ─── Operator-secret transport (shared with `tot pr list --tenant` / u16/u17) ────
102
+ //
103
+ // `resolveOperatorSecret` / `normalizeChangesQueue` are the SAME helpers the
104
+ // retired operator-by-PR ship path minted; `tot pr list --tenant` (`pr.mjs`)
105
+ // still imports them for its own (unrelated) `GET /api/changes` candidate-queue
106
+ // listing, so they stay here rather than move — one wire, one place it's typed.
107
+
108
+ const OPERATOR_SECRET_ENV = ["PREVIEW_RECONCILE_SECRET", "GRANTS_ADMIN_SECRET", "TOT_OPERATOR_SECRET"];
109
+
110
+ /**
111
+ * Resolve the operator secret from an explicit `--secret` or the env (first
112
+ * found). Pure given its env argument.
113
+ * @param {string|null|undefined} explicit
114
+ * @param {NodeJS.ProcessEnv} env
115
+ * @returns {string}
116
+ */
117
+ export function resolveOperatorSecret(explicit, env = {}) {
118
+ if (explicit && `${explicit}`.trim()) return `${explicit}`.trim();
119
+ for (const k of OPERATOR_SECRET_ENV) {
120
+ const v = env[k];
121
+ if (v && `${v}`.trim()) return `${v}`.trim();
122
+ }
123
+ return "";
124
+ }
125
+
126
+ /**
127
+ * Normalise a `GET /api/changes` body to the OPEN candidate list. Kept for
128
+ * `tot pr list --tenant` (`pr.mjs`) — NOT used by ship itself anymore (ship
129
+ * reads the aggregate plan from `/api/changes/ship`, a distinct endpoint).
130
+ * @param {any} data
131
+ * @returns {Array<{changeId:string, prNumber?:number|null, headSha?:string|null, previewUrl?:string|null}>}
132
+ */
133
+ export function normalizeChangesQueue(data) {
134
+ const list = Array.isArray(data)
135
+ ? data
136
+ : data && Array.isArray(data.changes)
137
+ ? data.changes
138
+ : [];
139
+ // Keep an entry if it has a changeId (a BUILT candidate) OR a numeric prNumber (a
140
+ // not-built forge PR merged in by GET /api/changes, u9b) — so `tot pr list --tenant`
141
+ // shows orphaned/un-built PRs too, not only built candidates. (Re-applied after a
142
+ // branch-scale ship rework reverted it to changeId-only.)
143
+ return list.filter(
144
+ (c) =>
145
+ c &&
146
+ typeof c === "object" &&
147
+ (typeof c.changeId === "string" || typeof c.prNumber === "number"),
148
+ );
149
+ }
150
+
151
+ // ─── Shared with `tot accept` (`accept.mjs`) ─────────────────────────────────────
152
+ //
153
+ // `tot accept` queues a PR's integration into the protected `preview` aggregate
154
+ // (NOT a merge to main — see accept.mjs's header) — a DISTINCT operator verb from
155
+ // ship (no deploy). It imports these two normalisers straight from here rather
156
+ // than re-implementing them.
157
+
158
+ /**
159
+ * Normalise a `change_accept` / `change_status` result to { shipped, state,
160
+ * previewUrl, shippedAt }. `shipped` is true once the record reports the shipped
161
+ * state (or carries a shipped stamp). Pure — unit-tested.
162
+ * @param {any} r
163
+ */
164
+ export function normalizeChangeResult(r) {
165
+ const o = (r && typeof r === "object" ? r : {});
166
+ const state = typeof o.state === "string" ? o.state : null;
167
+ const shippedStamp = o.shipped && typeof o.shipped === "object" ? o.shipped : null;
168
+ const shipped = state === "shipped" || Boolean(o.shipped === true || shippedStamp);
169
+ return {
170
+ shipped,
171
+ state,
172
+ previewUrl: o.previewUrl ?? shippedStamp?.previewUrl ?? o.liveUrl ?? null,
173
+ shippedAt: shippedStamp?.shippedAt ?? o.shippedAt ?? null,
174
+ raw: r,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Poll `change_status` until the change reports shipped (or the attempts budget
180
+ * runs out). Injectable delay/attempts.
181
+ * @param {{callTool:Function}} client
182
+ * @param {{ id:string, tenant?:string }} target
183
+ * @param {{ attempts?:number, delayMs?:number, sleep?:(ms:number)=>Promise<void> }} [opts]
184
+ */
185
+ export async function pollChangeShipped(client, { id, tenant }, { attempts = 6, delayMs = 1500, sleep } = {}) {
186
+ const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
187
+ let last = null;
188
+ for (let i = 0; i < attempts; i++) {
189
+ last = normalizeChangeResult(await client.callTool("change_status", { id, ...(tenant ? { tenant } : {}) }));
190
+ if (last.shipped) return last;
191
+ if (i < attempts - 1) await wait(delayMs);
192
+ }
193
+ return last;
194
+ }
195
+
196
+ // ─── URL derivation (pure) ─────────────────────────────────────────────────────
197
+
198
+ /** The live site URL for a tenant (the tenant IS its apex domain, e.g. tokenoftrust.com). Pure. */
199
+ export function liveUrlFor(tenant) {
200
+ const t = (tenant || "").trim();
201
+ return t ? `https://${t}` : null;
202
+ }
203
+
204
+ // ─── Response normalisation (defensive — one endpoint, but shapes may vary) ─────
205
+
206
+ /**
207
+ * Normalise a `GET /api/changes/ship` body — b09's `AggregateShipPlan` (a
208
+ * shippable aggregate) or `AggregateShipRefusal` (nothing shippable), read
209
+ * defensively since it crossed the wire as JSON. Pure — unit-tested.
210
+ * @param {any} data
211
+ * @returns {{ok:true, tenantId:string|null, pinnedSha:string|null, artifactDigest:string|null,
212
+ * includedPrs:Array<{prNumber?:number|null,changeId?:string|null,headSha?:string|null}>,
213
+ * integrationRunId:string|null, receiptId:string|null,
214
+ * rollbackTarget:{receiptId:string,aggregateSha:string,artifactDigest?:string}|null,
215
+ * paywall:{allowed:boolean,message:string|null}, alreadyShipped:boolean} |
216
+ * {ok:false, reason:string, message:string}}
217
+ */
218
+ export function normalizeShipPlan(data) {
219
+ const o = data && typeof data === "object" ? data : {};
220
+ if (o.ok === true) {
221
+ return {
222
+ ok: true,
223
+ tenantId: typeof o.tenantId === "string" ? o.tenantId : null,
224
+ pinnedSha: typeof o.pinnedSha === "string" ? o.pinnedSha : null,
225
+ artifactDigest: typeof o.artifactDigest === "string" ? o.artifactDigest : null,
226
+ includedPrs: Array.isArray(o.includedPrs) ? o.includedPrs : [],
227
+ integrationRunId: typeof o.integrationRunId === "string" ? o.integrationRunId : null,
228
+ receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
229
+ rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
230
+ paywall:
231
+ o.paywall && typeof o.paywall === "object"
232
+ ? {
233
+ allowed: o.paywall.allowed === true,
234
+ message: typeof o.paywall.message === "string" ? o.paywall.message : null,
235
+ }
236
+ : { allowed: true, message: null },
237
+ alreadyShipped: o.alreadyShipped === true,
238
+ };
239
+ }
240
+ return {
241
+ ok: false,
242
+ reason: typeof o.reason === "string" ? o.reason : "unknown",
243
+ message: typeof o.message === "string" ? o.message : "the ship plan was refused for an unknown reason",
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Normalise a `POST /api/changes/ship` body — b09's `AggregateShipResult`.
249
+ * `state` is the ONLY honest terminal-state authority: render "shipped live"
250
+ * for `"shipped"` and nothing else. Pure — unit-tested.
251
+ * @param {any} data
252
+ */
253
+ export function normalizeShipResult(data) {
254
+ const o = data && typeof data === "object" ? data : {};
255
+ return {
256
+ ok: o.ok === true,
257
+ state: typeof o.state === "string" ? o.state : "unknown",
258
+ reason: typeof o.reason === "string" ? o.reason : null,
259
+ message: typeof o.message === "string" ? o.message : "",
260
+ pinnedSha: typeof o.pinnedSha === "string" ? o.pinnedSha : null,
261
+ artifactDigest: typeof o.artifactDigest === "string" ? o.artifactDigest : null,
262
+ receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
263
+ rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
264
+ };
265
+ }
266
+
267
+ /** A clear next step per plan/ship refusal reason. Pure. */
268
+ export function refusalNextStep(reason) {
269
+ switch (reason) {
270
+ case "not_green":
271
+ case "no_passed_run":
272
+ return "land PRs into the shared preview queue and wait for it to go green, then re-run `tot ship`";
273
+ case "sha_mismatch":
274
+ case "digest_mismatch":
275
+ return "the aggregate moved since you reviewed it — re-run `tot ship` to review the current plan";
276
+ case "artifact_missing":
277
+ return "re-integrate to re-materialize the artifact (b09 never rebuilds at ship), then re-run `tot ship`";
278
+ case "paywall":
279
+ return "upgrade the storefront subscription to enable go-live";
280
+ case "golive_unconfirmed":
281
+ return "re-run `tot ship` and confirm the plan";
282
+ default:
283
+ return "re-run `tot ship`";
284
+ }
285
+ }
286
+
287
+ /** Read a fetch Response body as JSON, tolerating a non-JSON/empty body. */
288
+ async function readJsonSafe(res) {
289
+ try {
290
+ return await res.json();
291
+ } catch {
292
+ return {};
293
+ }
294
+ }
295
+
296
+ // ─── Orchestration ───────────────────────────────────────────────────────────────
297
+
298
+ /**
299
+ * The ship flow: GET the plan, print it + confirm, POST to ship, report the
300
+ * honest terminal state. `fetch`/`confirmPlan`/`openUrl`/`progress` are
301
+ * injected so it's unit-tested with no live network/TTY.
302
+ *
303
+ * @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
304
+ * yes?:boolean, noOpen?:boolean }} params
305
+ * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
306
+ * openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
307
+ * @returns {Promise<number>} process exit code
308
+ */
309
+ export async function runShip({ tenant, secret, storefrontUrl = null, yes = false, noOpen = false }, deps = {}) {
310
+ const fetchImpl = deps.fetch || globalThis.fetch;
311
+ const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
312
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
313
+
314
+ if (!secret) {
315
+ console.error(
316
+ fail(
317
+ "shipping is an OPERATOR action — it needs an operator secret",
318
+ "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
319
+ ),
320
+ );
321
+ return 2;
322
+ }
323
+
324
+ const authHeaders = {
325
+ authorization: `Bearer ${secret}`,
326
+ "x-tot-owner": tenant,
327
+ "x-tot-capability": "ship-on-behalf",
328
+ };
329
+
330
+ // 1. GET the read-only plan: the pinned sha + artifact digest + included PRs +
331
+ // rollback target + paywall verdict. Zero side effects.
332
+ let planRes;
333
+ try {
334
+ planRes = await fetchImpl(`${base}/api/changes/ship`, { method: "GET", headers: authHeaders });
335
+ } catch (e) {
336
+ console.error(
337
+ fail(`couldn't reach the ship plan at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
338
+ );
339
+ return 1;
340
+ }
341
+ const planData = await readJsonSafe(planRes);
342
+ if (!planRes.ok && planData?.ok !== false) {
343
+ // A transport-level refusal the endpoint didn't explain in its own shape
344
+ // (auth/tenant-resolution/network) — surface it honestly.
345
+ const msg = planData?.error || `HTTP ${planRes.status}`;
346
+ console.error(
347
+ fail(
348
+ `the ship plan was refused: ${msg}`,
349
+ planRes.status === 401 || planRes.status === 403
350
+ ? "check the operator secret and that it's authorised for this tenant"
351
+ : "check --tenant / --url, then re-run",
352
+ ),
353
+ );
354
+ return 1;
355
+ }
356
+ const plan = normalizeShipPlan(planData);
357
+ if (!plan.ok) {
358
+ console.error(fail(plan.message, refusalNextStep(plan.reason)));
359
+ return 1;
360
+ }
361
+
362
+ // 2. Print the EXACT plan via the shared U10 affordance, then require ONE
363
+ // explicit confirm (`--yes` non-interactive; a non-TTY without it refuses).
364
+ const liveUrl = liveUrlFor(tenant);
365
+ const planLines = planForAction({
366
+ action: "ship",
367
+ tenant,
368
+ pinnedSha: plan.pinnedSha,
369
+ artifactDigest: plan.artifactDigest,
370
+ includedPrs: plan.includedPrs,
371
+ rollbackTarget: plan.rollbackTarget,
372
+ paywall: plan.paywall,
373
+ targets: { live: liveUrl },
374
+ });
375
+ const { confirmed, reason } = await confirmPlan(planLines, {
376
+ yes,
377
+ question: `Ship ${tenant}'s current green aggregate live?`,
378
+ });
379
+ if (!confirmed) {
380
+ if (reason === "non-tty") {
381
+ console.error(
382
+ fail(
383
+ "`tot ship` needs an interactive terminal to confirm this live change",
384
+ "run it from a terminal, or pass --yes to confirm non-interactively",
385
+ ),
386
+ );
387
+ return 2;
388
+ }
389
+ console.log(" Ship cancelled — nothing changed.");
390
+ return 0;
391
+ }
392
+
393
+ // 3. SHIP — the human go-live gate (confirmGoLive) plus the reviewed pin, so a
394
+ // STALE plan (the aggregate moved / the artifact changed since GET) is
395
+ // refused rather than shipping something nobody reviewed.
396
+ const progress = deps.progress === false ? null : startProgress("shipping…");
397
+ let shipRes;
398
+ try {
399
+ shipRes = await fetchImpl(`${base}/api/changes/ship`, {
400
+ method: "POST",
401
+ headers: { "content-type": "application/json", ...authHeaders },
402
+ body: JSON.stringify({
403
+ confirmGoLive: true,
404
+ ...(plan.pinnedSha ? { expectedAggregateSha: plan.pinnedSha } : {}),
405
+ ...(plan.artifactDigest ? { expectedArtifactDigest: plan.artifactDigest } : {}),
406
+ }),
407
+ });
408
+ } catch (e) {
409
+ progress?.stop();
410
+ console.error(
411
+ fail(`couldn't reach the ship endpoint at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
412
+ );
413
+ return 1;
414
+ }
415
+ const shipData = await readJsonSafe(shipRes);
416
+ progress?.stop();
417
+
418
+ const shipResult = normalizeShipResult(shipData);
419
+ // D3: emit the ship publish lifecycle event — `tot ship` does no LOCAL git op
420
+ // (it's HTTP-orchestrated), so this marks the outcome of the publish step
421
+ // itself, distinct from the outer command's invoked/result pair. Fire-and-
422
+ // forget best-effort: a silent no-op without a hosted-bridge credential, never
423
+ // awaited, never throws, never alters the ship. `errorClass` stays low-
424
+ // cardinality (the orchestrator's own terminal state/reason, never free text).
425
+ const shipped = shipResult.state === "shipped";
426
+ void emitActivity({
427
+ action: "cli.command.result",
428
+ outcome: {
429
+ status: shipped ? "succeeded" : "failed",
430
+ ...(shipped ? {} : { errorClass: `ship_${shipResult.state}` }),
431
+ },
432
+ scope: { tenantId: tenant },
433
+ payload: { args: { command: "ship", subcommand: "ship.publish" } },
434
+ });
435
+
436
+ return reportShipResult(shipResult, { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
437
+ }
438
+
439
+ /**
440
+ * Report the orchestrator's HONEST terminal state. `shipped` is the ONLY state
441
+ * rendered as "shipped live" — `refused`/`promote_failed`/`record_failed` are
442
+ * surfaced with their own message + a clear next step, never dressed up as a
443
+ * success and never silently swallowed.
444
+ * @returns {number} process exit code
445
+ */
446
+ function reportShipResult(result, { tenant, liveUrl, noOpen, openUrl }) {
447
+ if (result.state === "shipped") {
448
+ console.log(`\n ✓ shipped ${tenant} live.`);
449
+ if (result.pinnedSha) console.log(` pinned: ${result.pinnedSha}`);
450
+ if (result.receiptId) console.log(` receipt: ${result.receiptId}`);
451
+ if (liveUrl) {
452
+ console.log(` Live: ${liveUrl}`);
453
+ if (!noOpen && openUrl && openUrl(liveUrl)) console.log(" (opened in your browser)");
454
+ }
455
+ if (result.message) console.log(` ${result.message}`);
456
+ return 0;
457
+ }
458
+ if (result.state === "refused") {
459
+ console.error(fail(result.message || `ship refused (${result.reason})`, refusalNextStep(result.reason)));
460
+ return 1;
461
+ }
462
+ if (result.state === "promote_failed" || result.state === "record_failed") {
463
+ console.error(
464
+ fail(
465
+ result.message || `ship ${result.state.replace("_", " ")}`,
466
+ "re-run `tot ship` — the ship is idempotent and safe to retry",
467
+ ),
468
+ );
469
+ return 1;
470
+ }
471
+ console.error(
472
+ fail(
473
+ `unexpected ship response (state: ${result.state})`,
474
+ "re-run `tot ship`; check the storefront logs if it persists",
475
+ ),
476
+ );
477
+ return 1;
478
+ }
479
+
480
+ /**
481
+ * @param {string[]} argv
482
+ * @param {any} ctx
483
+ */
484
+ export async function run(argv, ctx) {
485
+ const env = process.env;
486
+ const args = parseShipArgs(argv);
487
+ if (args.help) {
488
+ console.log(USAGE);
489
+ return 0;
490
+ }
491
+
492
+ // ONE meaning, no context detection: the target tenant is either named
493
+ // explicitly (`--tenant`, works from anywhere — no checkout needed) or
494
+ // inferred from the current checkout. Never guessed from a PR/branch/actor.
495
+ const tenant = (args.tenant || (ctx.mode === "checkout" ? ctx.tenant : null) || "").trim();
496
+ if (!tenant) {
497
+ console.error(
498
+ fail(
499
+ "`tot ship` needs a target tenant",
500
+ "run it from inside a `tot clone`d store, or pass --tenant <appDomain>",
501
+ ),
502
+ );
503
+ return 2;
504
+ }
505
+
506
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
507
+ return await runShip(
508
+ {
509
+ tenant,
510
+ secret: resolveOperatorSecret(args.secret, env),
511
+ storefrontUrl,
512
+ yes: args.yes,
513
+ noOpen: args.noOpen,
514
+ },
515
+ { openUrl: (u) => openBrowser(u) },
516
+ );
517
+ }
@@ -57,8 +57,9 @@ import { CliError, fail, formatError, exitCodeFor } from "../errors.mjs";
57
57
  import { openBrowser, waitForServer, firstFreePort } from "../open.mjs";
58
58
  import { startProgress } from "../progress.mjs";
59
59
  import { defaultLastTenantPath, readLastTenant, writeLastTenant } from "../last-tenant.mjs";
60
+ import { readCredentials, defaultCredentialsPath } from "../token-store.mjs";
60
61
  import { collectChecks } from "./doctor.mjs";
61
- import { normalizeStores, storeListError, checkoutTenant } from "./checkout.mjs";
62
+ import { normalizeStores, storeListError, checkoutTenant, noStoresGuidance } from "./clone.mjs";
62
63
  import {
63
64
  buildContainerPlan, spawnDevContainer, dockerAvailable, tryStartDocker,
64
65
  resolveDevImage, isPrivateRegistryImage, ensureRegistryLogin,
@@ -161,6 +162,24 @@ export function decideStartMode({ sampleFlag, hasSession }) {
161
162
  return hasSession ? "authed" : "sample";
162
163
  }
163
164
 
165
+ /**
166
+ * The MCP base URL the cached session was minted on — what `tot login` stored in the
167
+ * credentials file (honoring TOT_PROFILE via defaultCredentialsPath). Lets `tot start`
168
+ * (and callers) FOLLOW wherever the developer signed in rather than defaulting to prod,
169
+ * which would look up a session on the wrong MCP and report "not signed in". Returns
170
+ * null when there's no cached session or it can't be read (falls through to the default).
171
+ * @param {NodeJS.ProcessEnv} env
172
+ * @returns {string|null}
173
+ */
174
+ export function cachedMcpUrl(env) {
175
+ try {
176
+ const creds = readCredentials(defaultCredentialsPath(env));
177
+ return creds && typeof creds.mcpUrl === "string" && creds.mcpUrl ? creds.mcpUrl : null;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
164
183
  /** @param {string[]} argv @param {any} ctx */
165
184
  export async function run(argv, ctx) {
166
185
  const env = process.env;
@@ -178,7 +197,11 @@ export async function run(argv, ctx) {
178
197
  }
179
198
 
180
199
  try {
181
- const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
200
+ // Resolve the MCP: an explicit flag / env wins, then the MCP the cached session was
201
+ // minted on (what `tot login` stored — so `start` FOLLOWS wherever you signed in
202
+ // instead of defaulting to prod and reporting "not signed in"), then the prod default.
203
+ const baseUrl =
204
+ args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || cachedMcpUrl(env) || DEFAULT_MCP_URL;
182
205
  const client = createMcpClient(baseUrl);
183
206
 
184
207
  // Resolve a session, tolerating a no-session / no-network condition so we can
@@ -223,6 +246,7 @@ export async function run(argv, ctx) {
223
246
  tenant = await resolveTenant(stores, args, env, baseUrl, {
224
247
  session,
225
248
  listErr: storeListError(listResp),
249
+ list: listResp,
226
250
  });
227
251
  } catch (e) {
228
252
  stopEarlyHeartbeat();
@@ -537,7 +561,7 @@ function mcpOrigin(baseUrl) {
537
561
  * (surface the reason — likely an auth/entitlement problem) from a genuinely empty
538
562
  * result (invite may still be propagating, or you need one). Points at `tot whoami`.
539
563
  */
540
- function noStoresError({ session, baseUrl, listErr }) {
564
+ function noStoresError({ session, baseUrl, listErr, list = null }) {
541
565
  const who = describeIdentity(session);
542
566
  const origin = mcpOrigin(baseUrl);
543
567
  if (listErr) {
@@ -546,14 +570,10 @@ function noStoresError({ session, baseUrl, listErr }) {
546
570
  { next: "run `tot whoami` to check your session, or `tot login` again — then re-run `tot start`" },
547
571
  );
548
572
  }
549
- return new CliError(
550
- `signed in as ${who} via ${origin}, but you have no stores to build on yet`,
551
- {
552
- next:
553
- "if you were just invited, it may still be propagating — try again in a minute; " +
554
- "otherwise ask your Token of Trust contact for a store invite (see `tot whoami`)",
555
- },
556
- );
573
+ // Status-aware (card c2): an UNLINKED identity is pointed at `tot link`, not the
574
+ // misleading "ask for a store invite" copy; the genuine zero-grants case keeps it.
575
+ const g = noStoresGuidance(list);
576
+ return new CliError(`signed in as ${who} via ${origin}, but ${g.headline}`, { next: g.next });
557
577
  }
558
578
 
559
579
  /**
@@ -561,7 +581,7 @@ function noStoresError({ session, baseUrl, listErr }) {
561
581
  * the remembered last tenant (A4) — then remember whatever was decided so the
562
582
  * next bare `tot start` doesn't have to ask again.
563
583
  */
564
- async function resolveTenant(stores, args, env, baseUrl, { session = null, listErr = null } = {}) {
584
+ async function resolveTenant(stores, args, env, baseUrl, { session = null, listErr = null, list = null } = {}) {
565
585
  const lastTenantPath = defaultLastTenantPath(env);
566
586
  const pick = pickTenant(stores, {
567
587
  explicit: args.tenant || null,
@@ -570,7 +590,7 @@ async function resolveTenant(stores, args, env, baseUrl, { session = null, listE
570
590
 
571
591
  let tenant;
572
592
  if (pick.kind === "none") {
573
- throw noStoresError({ session, baseUrl, listErr });
593
+ throw noStoresError({ session, baseUrl, listErr, list });
574
594
  } else if (pick.kind === "explicit") {
575
595
  tenant = pick.tenant;
576
596
  console.log(` → your store: ${tenant} (--tenant)`);
@@ -613,7 +633,7 @@ async function ensureCheckout(client, tenant, dir, env) {
613
633
  console.log(` ✓ reusing existing checkout ./${tenant}`);
614
634
  return;
615
635
  }
616
- throw new CliError(`./${tenant} already exists and isn't a tot checkout`, {
636
+ throw new CliError(`./${tenant} already exists and isn't a store checkout`, {
617
637
  next: `remove it (or run \`tot start\` from an empty directory)`,
618
638
  exitCode: 2,
619
639
  });
@@ -675,8 +695,7 @@ function printCheckoutLanding(dir, cockpitUrl = null) {
675
695
 
676
696
  /** The crafted "you're live" ending — a PROMINENT milestone banner (u2) that,
677
697
  * when we hold a Developer Cockpit URL, explicitly sends the developer BACK to
678
- * their cockpit as the next place to look; then the AI-wow (G), seeded with
679
- * IDEAS[0] (G2/G3 — one prompt list shared with `tot ideas`, no drift). */
698
+ * their cockpit as the next place to look. */
680
699
  function printLiveEnding(tenant, url, elapsed, cockpitUrl = null) {
681
700
  const lines = [
682
701
  `✨ You're live.${elapsed ? ` (${elapsed})` : ""}`,
@@ -691,11 +710,6 @@ function printLiveEnding(tenant, url, elapsed, cockpitUrl = null) {
691
710
  console.log(milestoneBanner(lines));
692
711
  console.log(" " + versionStamp("native"));
693
712
  console.log("");
694
- console.log(" Now try, in Claude:");
695
- console.log(` "${IDEAS[0]}"`);
696
- console.log("");
697
- console.log(" More ideas: tot ideas");
698
- console.log("");
699
713
  }
700
714
 
701
715
  /**