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

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.
Files changed (47) hide show
  1. package/README.md +12 -9
  2. package/bin/tot.mjs +219 -44
  3. package/package.json +7 -2
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +2 -2
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +137 -0
  8. package/src/commands/accept.mjs +736 -0
  9. package/src/commands/app/dev.mjs +7 -3
  10. package/src/commands/app/index.mjs +2 -2
  11. package/src/commands/branches.mjs +297 -0
  12. package/src/commands/cleanup.mjs +269 -0
  13. package/src/commands/clone.mjs +713 -0
  14. package/src/commands/dev.mjs +441 -93
  15. package/src/commands/doctor.mjs +4 -3
  16. package/src/commands/git-credential.mjs +180 -0
  17. package/src/commands/go-live.mjs +486 -0
  18. package/src/commands/grants.mjs +14 -7
  19. package/src/commands/hotfix.mjs +428 -0
  20. package/src/commands/link.mjs +225 -0
  21. package/src/commands/login.mjs +12 -8
  22. package/src/commands/pr.mjs +425 -0
  23. package/src/commands/preview-build.mjs +225 -0
  24. package/src/commands/preview.mjs +80 -0
  25. package/src/commands/retire.mjs +203 -0
  26. package/src/commands/revert.mjs +322 -0
  27. package/src/commands/rollback.mjs +403 -0
  28. package/src/commands/ship.mjs +517 -0
  29. package/src/commands/start.mjs +91 -29
  30. package/src/commands/submit.mjs +1360 -131
  31. package/src/commands/sync.mjs +203 -0
  32. package/src/commands/validate.mjs +11 -5
  33. package/src/commands/whoami.mjs +6 -2
  34. package/src/context.mjs +2 -2
  35. package/src/dev-heartbeat.mjs +2 -1
  36. package/src/errors.mjs +8 -4
  37. package/src/git-credential.mjs +185 -0
  38. package/src/mcp.mjs +6 -1
  39. package/src/no-gitea-links.test.mjs +55 -0
  40. package/src/oauth.mjs +26 -11
  41. package/src/obstacle-beacon.cjs +3 -3
  42. package/src/obstacle.mjs +1 -1
  43. package/src/plan.mjs +262 -0
  44. package/src/sample.mjs +30 -4
  45. package/src/validate.mjs +56 -0
  46. package/src/viewer-session.mjs +118 -0
  47. package/src/commands/checkout.mjs +0 -330
@@ -0,0 +1,486 @@
1
+ /**
2
+ * `tot go-live` — cut the apex domain over to the storefront (unit u9). The CLI
3
+ * counterpart to the admin Publish tab's Domain "Connect" button: it drives the
4
+ * SAME go-live CI/dispatch the button does (the storefront `/api/domain/dispatch`
5
+ * → `www-domain` repository_dispatch → `cutover-dns.mjs`), gated by the SAME
6
+ * server-authoritative readiness gate the admin display reads
7
+ * (`GET /api/domain/readiness`, unit u9). This command CONSUMES those seams — it
8
+ * does NOT reimplement the DNS cutover, the readiness policy, or the revert.
9
+ *
10
+ * tot go-live show apex readiness, then (if ready) connect the apex
11
+ * tot go-live --rehearsal dispatch a DRY-RUN cutover (never moves DNS state)
12
+ * tot go-live --rollback restore the captured prior DNS records (the one-line revert)
13
+ *
14
+ * The contract, deliberately strict — this is the apex DNS cutover of the live
15
+ * site (mirrors `tot ship` / `tot rollback`):
16
+ *
17
+ * 1. GET the readiness verdict and ALWAYS print the itemization (owner
18
+ * capability, dual-run parity, target health, evidence freshness, captured
19
+ * revert). Fail-closed: a CONNECT is refused unless the server reports
20
+ * `ready:true` — the CLI never recomputes readiness, it reads the same gate
21
+ * the cutover is governed by, so they cannot diverge.
22
+ * 2. Owner-only: the server resolves owner capability (u10); a non-owner is
23
+ * DENIED with the itemized reason. The CLI does not assert ownership itself.
24
+ * 3. ALWAYS require ONE explicit [y/N] confirm (default NO). There is NO
25
+ * `--yes`/`--force`; in a NON-TTY (CI, piped) it REFUSES rather than
26
+ * auto-confirm — nothing cuts over without a human at the keyboard.
27
+ * 4. On confirm, POST the dispatch and report the REAL terminal state — a
28
+ * dispatched run is IN FLIGHT until the CI HMAC callback lands; the CLI polls
29
+ * `/api/domain/status` and never claims "cut over" before the callback
30
+ * confirms it. A tested one-line revert (`tot go-live --rollback`) is always
31
+ * surfaced.
32
+ *
33
+ * Dependency-free (global fetch); pure helpers are exported and unit-tested with a
34
+ * mock HTTP client, no network, no TTY, and NO live DNS.
35
+ */
36
+ import { fail } from "../errors.mjs";
37
+ import { isInteractive, promptYesNo } from "../prompt.mjs";
38
+ import { startProgress } from "../progress.mjs";
39
+ import { openBrowser } from "../open.mjs";
40
+
41
+ const USAGE = `tot go-live — cut the apex domain over to the storefront
42
+
43
+ tot go-live show apex readiness, then (if ready) connect the apex
44
+ tot go-live --rehearsal dispatch a DRY-RUN cutover (never moves DNS state)
45
+ tot go-live --rollback restore the captured prior DNS records (the revert)
46
+ tot go-live --url <base> storefront base URL (default: env TOT_STOREFRONT_URL / https://<owner>)
47
+ tot go-live --owner <domain> owner/appDomain to act on (default: env TOT_STOREFRONT_OWNER / checkout tenant)
48
+ tot go-live --no-open don't open the store in your browser afterward
49
+
50
+ go-live drives the SAME apex cutover the admin Domain button does, gated by the
51
+ same server readiness gate. It ALWAYS shows you the readiness itemization and
52
+ asks for a single y/N confirmation first; a CONNECT is refused unless the server
53
+ reports ready. There is no --yes/--force, and it refuses to run without an
54
+ interactive terminal. Reversible: \`tot go-live --rollback\`.`;
55
+
56
+ /** Parse `tot go-live` argv. Pure. Deliberately NO --yes/--force (see the header). */
57
+ export function parseGoLiveArgs(argv) {
58
+ const a = {
59
+ action: "connect",
60
+ rehearsal: false,
61
+ url: null,
62
+ owner: null,
63
+ identity: null,
64
+ noOpen: false,
65
+ help: false,
66
+ };
67
+ for (let i = 0; i < argv.length; i++) {
68
+ const t = argv[i];
69
+ if (t === "--rehearsal" || t === "--dry-run") a.rehearsal = true;
70
+ else if (t === "--rollback") a.action = "rollback";
71
+ else if (t === "--connect") a.action = "connect";
72
+ else if (t === "--url") a.url = argv[++i];
73
+ else if (t === "--owner") a.owner = argv[++i];
74
+ else if (t === "--identity") a.identity = argv[++i];
75
+ else if (t === "--no-open") a.noOpen = true;
76
+ else if (t === "--help" || t === "-h") a.help = true;
77
+ else if (t === "rollback") a.action = "rollback";
78
+ else if (t === "connect") a.action = "connect";
79
+ }
80
+ return a;
81
+ }
82
+
83
+ // ─── Response normalisation (defensive — one server, but shapes may vary) ─────────
84
+
85
+ /**
86
+ * Normalise a `GET /api/domain/readiness` response to what the gate needs:
87
+ * whether the acting principal is owner-capable, the readiness verdict + its
88
+ * itemized checks, and the current domain platform. TRI-STATE-safe: `ready`/
89
+ * `ownerCapable` are only true when the server clearly says so, so an unrecognised
90
+ * shape fails CLOSED (we never connect on ambiguity). Pure — unit-tested.
91
+ * @param {any} r
92
+ */
93
+ export function normalizeReadiness(r) {
94
+ const o = r && typeof r === "object" ? r : {};
95
+ const rd = o.readiness && typeof o.readiness === "object" ? o.readiness : {};
96
+ const checks = Array.isArray(rd.checks)
97
+ ? rd.checks
98
+ .filter((c) => c && typeof c === "object")
99
+ .map((c) => ({
100
+ id: typeof c.id === "string" ? c.id : "?",
101
+ label: typeof c.label === "string" ? c.label : c.id || "?",
102
+ ok: c.ok === true,
103
+ detail: typeof c.detail === "string" ? c.detail : "",
104
+ }))
105
+ : [];
106
+ return {
107
+ ownerCapable: o.ownerCapable === true,
108
+ ready: rd.ready === true,
109
+ checks,
110
+ blockedReasons: Array.isArray(rd.blockedReasons)
111
+ ? rd.blockedReasons.filter((s) => typeof s === "string")
112
+ : checks.filter((c) => !c.ok).map((c) => c.detail),
113
+ appDomain: typeof o.appDomain === "string" ? o.appDomain : null,
114
+ domainState: rd && o.domain && typeof o.domain === "object" ? o.domain : null,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Normalise a `POST /api/domain/dispatch` response to { dispatched, runId, error }.
120
+ * `dispatched` is true only when the server confirms it fired the CI dispatch. Pure.
121
+ * @param {any} r
122
+ */
123
+ export function normalizeDispatch(r) {
124
+ const o = r && typeof r === "object" ? r : {};
125
+ return {
126
+ dispatched: o.dispatched === true,
127
+ runId: typeof o.runId === "string" ? o.runId : null,
128
+ error: typeof o.error === "string" ? o.error : null,
129
+ domain: o.domain && typeof o.domain === "object" ? o.domain : null,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Normalise a `GET /api/domain/status` response's run to a terminal verdict:
135
+ * { status: "dispatched"|"succeeded"|"failed"|null, runUrl, error, state }. The
136
+ * connect is CUT OVER only when the last run reports `succeeded` (non-rehearsal) —
137
+ * never inferred from the dispatch. Pure — unit-tested.
138
+ * @param {any} r
139
+ */
140
+ export function normalizeRunStatus(r) {
141
+ const o = r && typeof r === "object" ? r : {};
142
+ const domain = o.domain && typeof o.domain === "object" ? o.domain : {};
143
+ const run = domain.lastRun && typeof domain.lastRun === "object" ? domain.lastRun : {};
144
+ return {
145
+ status: typeof run.status === "string" ? run.status : null,
146
+ action: typeof run.action === "string" ? run.action : null,
147
+ rehearsal: run.rehearsal === true,
148
+ runUrl: typeof run.runUrl === "string" ? run.runUrl : null,
149
+ error: typeof run.error === "string" ? run.error : null,
150
+ platformState: typeof domain.state === "string" ? domain.state : null,
151
+ };
152
+ }
153
+
154
+ // ─── The go-live gate (pure) ──────────────────────────────────────────────────────
155
+
156
+ /**
157
+ * Decide, from the normalised readiness, whether a CONNECT may proceed — PURE so
158
+ * every branch is unit-tested without HTTP/TTY. Order: owner first (the most
159
+ * fundamental denial), then the full readiness verdict. A rollback does NOT gate
160
+ * on dual-run parity (it restores prior records); the server's `beginDomainRun`
161
+ * enforces "no captured prior records ⇒ refused", so rollback only needs owner +
162
+ * not-in-flight here and lets the server be the authority.
163
+ *
164
+ * @param {ReturnType<typeof normalizeReadiness>} readiness
165
+ * @param {"connect"|"rollback"} action
166
+ * @returns {{ kind: "not-owner"|"not-ready"|"ready", blockers: string[] }}
167
+ */
168
+ export function goLiveReadinessGate(readiness, action) {
169
+ if (!readiness.ownerCapable) {
170
+ return {
171
+ kind: "not-owner",
172
+ blockers: [
173
+ "apex cutover is owner-only — sign in as the store owner (a ship-on-behalf developer cannot cut over the apex)",
174
+ ],
175
+ };
176
+ }
177
+ if (action === "rollback") {
178
+ // Rollback readiness = owner (above) + not mid-run. The server enforces the
179
+ // captured-records precondition; don't block the revert on connect-only signals.
180
+ const inFlight = readiness.checks.find((c) => c.id === "no-run-in-flight");
181
+ if (inFlight && !inFlight.ok) {
182
+ return { kind: "not-ready", blockers: [inFlight.detail] };
183
+ }
184
+ return { kind: "ready", blockers: [] };
185
+ }
186
+ if (!readiness.ready) {
187
+ return { kind: "not-ready", blockers: readiness.blockedReasons };
188
+ }
189
+ return { kind: "ready", blockers: [] };
190
+ }
191
+
192
+ // ─── Rendering (pure) ─────────────────────────────────────────────────────────────
193
+
194
+ /** Render the readiness itemization — one ✓/✗ line per check + a headline. Pure. */
195
+ export function renderReadiness({ appDomain, readiness, action }) {
196
+ const lines = ["", ` Apex ${action === "rollback" ? "rollback" : "cutover"} readiness for ${appDomain ?? "this store"}:`];
197
+ for (const c of readiness.checks) {
198
+ lines.push(` ${c.ok ? "✓" : "✗"} ${c.label}${c.detail ? ` — ${c.detail}` : ""}`);
199
+ // Blocked checks carry a "how to clear this" remedy from the server gate —
200
+ // the same guidance the admin panel shows, so the two surfaces never diverge.
201
+ if (!c.ok && c.remedy) lines.push(` → ${c.remedy}`);
202
+ }
203
+ if (action === "connect") {
204
+ lines.push(
205
+ "",
206
+ readiness.ready
207
+ ? " All preconditions green — this cutover is permitted."
208
+ : " Not ready — the failing preconditions above block the cutover.",
209
+ );
210
+ }
211
+ return lines;
212
+ }
213
+
214
+ // ─── Status poll ───────────────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Poll `GET /api/domain/status` until the in-flight run reaches a terminal state
218
+ * (succeeded|failed) or the attempts budget runs out. The dispatch is async — CI
219
+ * runs then POSTs the HMAC callback — so this CONFIRMS the real outcome instead of
220
+ * assuming it from the dispatch. Injectable delay/attempts. Returns the last
221
+ * normalised run status (may still be "dispatched" if CI is slow — reported honestly).
222
+ * @param {{ get:(path:string)=>Promise<any> }} http
223
+ * @param {{ attempts?: number, delayMs?: number, sleep?: Function }} [opts]
224
+ */
225
+ export async function pollRunTerminal(http, { attempts = 8, delayMs = 2000, sleep } = {}) {
226
+ const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
227
+ let last = null;
228
+ for (let i = 0; i < attempts; i++) {
229
+ last = normalizeRunStatus(await http.get("/api/domain/status"));
230
+ if (last.status === "succeeded" || last.status === "failed") return last;
231
+ if (i < attempts - 1) await wait(delayMs);
232
+ }
233
+ return last;
234
+ }
235
+
236
+ // ─── Orchestration ───────────────────────────────────────────────────────────────
237
+
238
+ /**
239
+ * The go-live flow over an HTTP client — read readiness, render, gate, confirm,
240
+ * dispatch, poll, report. Split out from `run` so it's driven in tests with a mock
241
+ * `{ get, post }` HTTP client + injected confirm/interactive, no network / TTY /
242
+ * live DNS.
243
+ *
244
+ * @param {{ get:(path:string)=>Promise<any>, post:(path:string,body:any)=>Promise<any> }} http
245
+ * @param {{ appDomain:string, action:"connect"|"rollback", rehearsal:boolean, noOpen?:boolean }} params
246
+ * @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
247
+ * poll?:typeof pollRunTerminal, openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
248
+ * @returns {Promise<number>} process exit code
249
+ */
250
+ export async function runGoLive(http, { appDomain, action, rehearsal, noOpen }, deps = {}) {
251
+ const interactive = deps.interactive || isInteractive;
252
+ const confirm = deps.confirm || promptYesNo;
253
+ const poll = deps.poll || pollRunTerminal;
254
+
255
+ // 1. Read the server-authoritative readiness verdict + ALWAYS itemize it.
256
+ let readiness;
257
+ try {
258
+ readiness = normalizeReadiness(await http.get("/api/domain/readiness"));
259
+ } catch (e) {
260
+ console.error(
261
+ fail(
262
+ `couldn't read apex readiness: ${String(e?.message || e)}`,
263
+ "check your connection and that the operator token/owner are set, then re-run",
264
+ ),
265
+ );
266
+ return 1;
267
+ }
268
+ const domain = appDomain || readiness.appDomain;
269
+ for (const line of renderReadiness({ appDomain: domain, readiness, action })) console.log(line);
270
+
271
+ // 2. Gate — owner-only + (for connect) fail-closed on the readiness verdict.
272
+ const gate = goLiveReadinessGate(readiness, action);
273
+ if (gate.kind === "not-owner") {
274
+ console.error(fail(gate.blockers[0], "the admin Domain tab connects the apex from the owner's signed-in session"));
275
+ return 1;
276
+ }
277
+ if (gate.kind !== "ready") {
278
+ console.error(
279
+ fail(
280
+ `apex ${action} is not ready`,
281
+ "clear the failing preconditions above (e.g. run the dual-run, capture prior records), then re-run",
282
+ ),
283
+ );
284
+ return 1;
285
+ }
286
+
287
+ // 3. ALWAYS require one explicit y/N confirm; refuse in a NON-TTY.
288
+ if (!interactive()) {
289
+ console.error(
290
+ fail(
291
+ `\`tot go-live${action === "rollback" ? " --rollback" : ""}\` needs an interactive terminal to confirm the live change`,
292
+ "run it from a terminal (there is intentionally no --yes/--force)",
293
+ ),
294
+ );
295
+ return 2;
296
+ }
297
+ const verb = action === "rollback" ? "roll the apex DNS back" : rehearsal ? "REHEARSE the apex cutover" : "cut the apex over";
298
+ const proceed = await confirm(`\n ${cap(verb)} for ${domain}?`, false);
299
+ if (!proceed) {
300
+ console.log(" Cancelled — nothing changed.");
301
+ return 0;
302
+ }
303
+
304
+ // 4. Dispatch the SAME go-live CI action the admin button fires. The connect
305
+ // requires the typed domain confirmation server-side (confirmDomain).
306
+ let dispatch;
307
+ try {
308
+ dispatch = normalizeDispatch(
309
+ await http.post("/api/domain/dispatch", {
310
+ action,
311
+ rehearsal,
312
+ ...(action === "connect" ? { confirmDomain: domain } : {}),
313
+ }),
314
+ );
315
+ } catch (e) {
316
+ console.error(
317
+ fail(
318
+ `the go-live dispatch was refused: ${String(e?.message || e)}`,
319
+ "the server gates the cutover (owner session + readiness); resolve the reason it reported, then re-run",
320
+ ),
321
+ );
322
+ return 1;
323
+ }
324
+ if (!dispatch.dispatched) {
325
+ console.error(
326
+ fail(
327
+ dispatch.error || "the go-live dispatch did not fire",
328
+ "the server refused the cutover — recheck `tot go-live` readiness and that you're the signed-in owner",
329
+ ),
330
+ );
331
+ return 1;
332
+ }
333
+
334
+ // 5. Poll for the REAL terminal state — never claim "cut over" before the CI
335
+ // callback confirms it.
336
+ const progress = deps.progress === false ? null : startProgress(action === "rollback" ? "rolling back…" : "cutting over…");
337
+ let status;
338
+ try {
339
+ status = await poll(http);
340
+ } finally {
341
+ progress?.stop();
342
+ }
343
+ return reportGoLive({ status, dispatch, action, rehearsal, domain, noOpen, openUrl: deps.openUrl });
344
+ }
345
+
346
+ /** Report the go-live outcome honestly + always surface the one-line revert. */
347
+ function reportGoLive({ status, dispatch, action, rehearsal, domain, noOpen, openUrl }) {
348
+ const runUrl = status?.runUrl || null;
349
+ const revertHint = " Revert (one line): tot go-live --rollback";
350
+
351
+ if (status?.status === "succeeded") {
352
+ if (rehearsal) {
353
+ console.log(`\n ✓ rehearsal succeeded for ${domain} — DNS state unchanged (dry run).`);
354
+ } else if (action === "rollback") {
355
+ console.log(`\n ✓ rolled the apex DNS back for ${domain} to the captured prior records.`);
356
+ } else {
357
+ console.log(`\n ✓ apex cut over for ${domain} — the storefront is now live on the apex.`);
358
+ const url = `https://${domain}`;
359
+ console.log(` Live: ${url}`);
360
+ if (!noOpen && openUrl && openUrl(url)) console.log(" (opened in your browser)");
361
+ }
362
+ if (action !== "rollback") console.log(revertHint);
363
+ return 0;
364
+ }
365
+
366
+ if (status?.status === "failed") {
367
+ console.error(
368
+ fail(
369
+ `the apex ${action} run failed${status.error ? `: ${status.error}` : ""}`,
370
+ action === "rollback"
371
+ ? "check the CI run, then retry"
372
+ : "the DNS was not changed if the run failed before cutover; check the CI run, then retry — or `tot go-live --rollback`",
373
+ ),
374
+ );
375
+ if (runUrl) console.error(` CI run: ${runUrl}`);
376
+ return 1;
377
+ }
378
+
379
+ // Dispatched but not yet terminal — CI is still running. Report honestly.
380
+ console.log(`\n ~ ${action} dispatched for ${domain}${dispatch.runId ? ` (run ${dispatch.runId})` : ""}; it's running now.`);
381
+ if (runUrl) console.log(` Track it: ${runUrl}`);
382
+ console.log(" Re-run `tot go-live` (or watch the admin Domain tab) for the terminal state.");
383
+ if (action !== "rollback") console.log(revertHint);
384
+ return 0;
385
+ }
386
+
387
+ function cap(s) {
388
+ return s.charAt(0).toUpperCase() + s.slice(1);
389
+ }
390
+
391
+ /**
392
+ * Build an HTTP client over the storefront apex-domain endpoints. Auth mirrors the
393
+ * headless operator trust boundary the storefront's `resolveOwnerSession` Path 2
394
+ * accepts: a Bearer operator secret + `X-Tot-Owner`, plus the `x-tot-capability`
395
+ * ship floor. Throws on a non-2xx with the server's error message (so the caller
396
+ * surfaces the real refusal). `fetchImpl` is injectable for tests.
397
+ * @param {string} base
398
+ * @param {{ token?:string, owner?:string, capability?:string, fetchImpl?:typeof fetch }} [auth]
399
+ */
400
+ export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", fetchImpl } = {}) {
401
+ const root = base.replace(/\/+$/, "");
402
+ const doFetch = fetchImpl || fetch;
403
+ const headers = () => ({
404
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
405
+ ...(owner ? { "x-tot-owner": owner } : {}),
406
+ "x-tot-capability": capability,
407
+ });
408
+ const parse = async (res) => {
409
+ const text = await res.text();
410
+ let body = null;
411
+ try {
412
+ body = text ? JSON.parse(text) : null;
413
+ } catch {
414
+ body = null;
415
+ }
416
+ if (!res.ok) {
417
+ const msg = (body && typeof body === "object" && typeof body.error === "string" && body.error) ||
418
+ `HTTP ${res.status}`;
419
+ throw new Error(msg);
420
+ }
421
+ return body;
422
+ };
423
+ return {
424
+ async get(path) {
425
+ return parse(await doFetch(`${root}${path}`, { method: "GET", headers: headers() }));
426
+ },
427
+ async post(path, body) {
428
+ return parse(
429
+ await doFetch(`${root}${path}`, {
430
+ method: "POST",
431
+ headers: { ...headers(), "content-type": "application/json" },
432
+ body: JSON.stringify(body ?? {}),
433
+ }),
434
+ );
435
+ },
436
+ };
437
+ }
438
+
439
+ /**
440
+ * @param {string[]} argv
441
+ * @param {any} ctx
442
+ */
443
+ export async function run(argv, ctx) {
444
+ const env = process.env;
445
+ const args = parseGoLiveArgs(argv);
446
+ if (args.help) {
447
+ console.log(USAGE);
448
+ return 0;
449
+ }
450
+ if (ctx.mode !== "checkout") {
451
+ console.error(
452
+ fail(
453
+ "`tot go-live` runs from inside a tenant checkout",
454
+ "tot clone <tenant> <dir> (then `cd` in, and `tot go-live`)",
455
+ ),
456
+ );
457
+ return 2;
458
+ }
459
+
460
+ // The owner/appDomain to act on, and the storefront base URL. Owner defaults to
461
+ // the checkout's tenant (owner == appDomain in this platform); the base URL
462
+ // defaults to https://<owner> unless overridden.
463
+ const owner = args.owner || env.TOT_STOREFRONT_OWNER || ctx.tenant;
464
+ if (!owner) {
465
+ console.error(
466
+ fail(
467
+ "couldn't resolve the store owner/appDomain for this checkout",
468
+ "pass --owner <appDomain> or set TOT_STOREFRONT_OWNER",
469
+ ),
470
+ );
471
+ return 1;
472
+ }
473
+ const base = args.url || env.TOT_STOREFRONT_URL || `https://${owner}`;
474
+ const token = env.TOT_STOREFRONT_OPERATOR_TOKEN || env.PREVIEW_RECONCILE_SECRET;
475
+
476
+ // The storefront apex-domain endpoints authenticate via the operator token +
477
+ // X-Tot-Owner (resolveOwnerSession Path 2), NOT the MCP OAuth session — so there
478
+ // is no MCP sign-in step here. Owner INTENT is enforced by the explicit confirm
479
+ // below and, authoritatively, by the server's owner-only readiness/dispatch gate.
480
+ const http = createStorefrontHttp(base, { token, owner });
481
+ return await runGoLive(
482
+ http,
483
+ { appDomain: owner, action: /** @type {any} */ (args.action), rehearsal: args.rehearsal, noOpen: args.noOpen },
484
+ { openUrl: (u) => openBrowser(u) },
485
+ );
486
+ }
@@ -26,7 +26,7 @@
26
26
  import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
27
27
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
28
28
  import { createMcpClient } from "../mcp.mjs";
29
- import { storeListError } from "./checkout.mjs";
29
+ import { storeListError, noStoresGuidance } from "./clone.mjs";
30
30
  import { offerSignIn } from "./login.mjs";
31
31
  import { recordServerPolicy } from "../update-check.mjs";
32
32
 
@@ -61,9 +61,10 @@ function parseArgs(argv) {
61
61
  * exp: number|null, revokedAt: string|null }> }}
62
62
  */
63
63
  export function normalizeGrantRows(resp) {
64
- if (resp && typeof resp === "object" && !Array.isArray(resp) && Array.isArray(resp.grants)) {
65
- const activeTenant = typeof resp.activeTenant === "string" ? resp.activeTenant : null;
66
- const rows = resp.grants
64
+ const rr = /** @type {any} */ (resp);
65
+ if (resp && typeof resp === "object" && !Array.isArray(resp) && Array.isArray(rr.grants)) {
66
+ const activeTenant = typeof rr.activeTenant === "string" ? rr.activeTenant : null;
67
+ const rows = rr.grants
67
68
  .map((g) => {
68
69
  const tenant = g?.tenant ?? g?.id ?? null;
69
70
  return {
@@ -83,7 +84,7 @@ export function normalizeGrantRows(resp) {
83
84
 
84
85
  // Fallback: client_list — store ids + environment + the selected marker only. No
85
86
  // grant detail is knowable here (grantActive/capability/tier/exp/revokedAt stay null).
86
- const clients = Array.isArray(resp) ? resp : resp?.clients || resp?.tenants || [];
87
+ const clients = Array.isArray(resp) ? resp : rr?.clients || rr?.tenants || [];
87
88
  const rows = (Array.isArray(clients) ? clients : [])
88
89
  .map((c) => ({
89
90
  tenant: c?.tenant ?? c?.id ?? c?.clientId ?? c?.appDomain ?? null,
@@ -147,6 +148,7 @@ export function formatExpiry(exp, { now = Date.now() } = {}) {
147
148
  export function describeGrantRow(row, { now = Date.now() } = {}) {
148
149
  const status = grantStatus(row, { now });
149
150
  if (status === "unknown") return "grant detail unavailable (server introspection not enabled)";
151
+ /** @type {string[]} */
150
152
  const parts = [status];
151
153
  if (row.capability) parts.push(`capability=${row.capability}`);
152
154
  if (row.tier) parts.push(`tier=${row.tier}`);
@@ -238,8 +240,13 @@ export async function run(argv, _ctx) {
238
240
  return 1;
239
241
  }
240
242
  if (rows.length === 0) {
241
- console.log("\nNo stores you can act on yet for this identity.");
242
- console.log("If you were just invited, it may still be propagating — try again in a minute.");
243
+ // Status-aware, like whoami/checkout (card c2): an UNLINKED identity is told to
244
+ // link, not given the "may still be propagating" copy that's only the genuine
245
+ // linked-but-zero-grants case.
246
+ const g = noStoresGuidance(resp);
247
+ const line = g.headline.charAt(0).toUpperCase() + g.headline.slice(1);
248
+ console.log(`\n${line}.`);
249
+ console.log(`Next: ${g.next}`);
243
250
  return 0;
244
251
  }
245
252