@tokenoftrust/cli 1.4.0-rc.9 → 1.4.1

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.
@@ -187,7 +187,8 @@ export async function run(argv, ctx) {
187
187
  }
188
188
  console.log(
189
189
  failed === 0
190
- ? "\n✔ Ready. Try: tot start (checks out your store and runs it), or tot clone\n"
190
+ ? "\n✔ Ready. Try: tot start (checks out your store and runs it), or tot clone." +
191
+ "\n The loop: tot dev → tot preview → tot ship.\n"
191
192
  : args.fix
192
193
  ? `\n✖ ${failed} check(s) still failing — see the exact next command(s) above, then re-run \`tot doctor\`.\n`
193
194
  : `\n✖ ${failed} check(s) failed — try \`tot doctor --fix\`, or fix the above, then re-run \`tot doctor\`.\n`,
@@ -0,0 +1,180 @@
1
+ /**
2
+ * `tot git-credential` — git's own credential-helper protocol (see `git help
3
+ * gitcredentials`), implemented over the developer's cached `tot login`
4
+ * session (unit u10, workstream tot-merge-conflict-resolution-ux). `tot
5
+ * clone` configures a fresh checkout's `credential.helper` to run this (see
6
+ * ../git-credential.mjs's CREDENTIAL_HELPER), so `git fetch`/`git push`/`git
7
+ * pull` — run DIRECTLY by the developer, not just through `tot preview` —
8
+ * transparently mint a fresh forge token instead of ever needing one
9
+ * persisted in `.git/config`. This is the fix for the "the panel-prescribed
10
+ * `git fetch origin` fails even after `tot login`" dead-end: `tot login`
11
+ * only ever refreshed the CLI's OWN MCP session, never the token baked into
12
+ * a checkout's remote URL at clone time.
13
+ *
14
+ * git invokes this with ONE positional arg (`get`/`store`/`erase`) and the
15
+ * request on stdin (key=value lines, blank-line/EOF terminated). Only `get`
16
+ * does real work — this CLI never persists a forge credential of its own
17
+ * beyond the short-lived cache in ../git-credential.mjs, so `store`/`erase`
18
+ * are no-ops (git calls them after a successful/failed auth respectively; we
19
+ * just drain stdin and exit 0, the correct behavior for a stateless helper).
20
+ *
21
+ * FAILS SILENT, NEVER LOUD: `get` prints NOTHING and exits non-zero on any
22
+ * problem (not signed in, MCP unreachable, cwd isn't a recognizable tenant
23
+ * checkout) — git then falls through to its next configured helper or its
24
+ * own prompt, exactly as if this helper weren't configured. A stack trace or
25
+ * a malformed credential line here would otherwise corrupt EVERY git
26
+ * operation in the checkout. It also NEVER triggers an interactive sign-in —
27
+ * this runs as a non-interactive subprocess of `git`, so a missing session
28
+ * fails through rather than trying to open a browser mid-`git fetch`.
29
+ *
30
+ * Dependency-free (global fetch + `git`, via the same MCP client + auth
31
+ * module every other command uses).
32
+ */
33
+ import { readFileSync } from "node:fs";
34
+ import { execFileSync } from "node:child_process";
35
+ import { createMcpClient } from "../mcp.mjs";
36
+ import { establishSession } from "../auth.mjs";
37
+ import { checkoutTenant } from "./clone.mjs";
38
+ import { detectContext } from "../context.mjs";
39
+ import { repoNameFromRemote, tagFromRepoName } from "./submit.mjs";
40
+ import {
41
+ parseCredentialInput, formatCredentialOutput, splitAuthedRemote,
42
+ credentialCachePath, readCachedCredential, writeCachedCredential,
43
+ } from "../git-credential.mjs";
44
+
45
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
46
+
47
+ /** Does `remoteUrl`'s host equal `host` (case-insensitive)? An unparseable or
48
+ * missing remote URL never matches — fail closed. Exported for testing. */
49
+ export function hostMatches(remoteUrl, host) {
50
+ if (!remoteUrl || !host) return false;
51
+ try {
52
+ // scp-like remotes (git@host:owner/repo.git) have no scheme — synthesize one so URL can parse the host.
53
+ const normalized = /^[^/]+@[^/:]+:/.test(remoteUrl) ? `ssh://${remoteUrl.replace(":", "/")}` : remoteUrl;
54
+ return new URL(normalized).host.toLowerCase() === host.toLowerCase();
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /** Read the credential request git writes to stdin — blocking is correct
61
+ * here: git writes the request then closes its end, so this returns as
62
+ * soon as it's fully sent. Never blocks on an interactive terminal (git
63
+ * NEVER attaches a TTY to a credential helper's stdin — only a human
64
+ * poking at this command directly by hand would) and never throws (an
65
+ * unreadable/absent stdin is treated as an empty request). Exported so a
66
+ * test can inject a canned request instead of touching real fd 0. */
67
+ export function readStdin() {
68
+ if (process.stdin.isTTY) return "";
69
+ try {
70
+ return readFileSync(0, "utf8");
71
+ } catch {
72
+ return "";
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Mint (or reuse a cached) forge credential for the tenant the CURRENT
78
+ * directory checks out — the same `tenant_checkout` MCP call `tot clone`
79
+ * itself uses, so the credential is exactly as push-capable. Returns null on
80
+ * ANY failure (wrong dir, not signed in, MCP unreachable) rather than
81
+ * throwing — `get` treats null as "say nothing, exit non-zero".
82
+ *
83
+ * `createClient`/`establish`/`checkout` are injected (default to the real MCP
84
+ * client + auth + clone.mjs's checkoutTenant) purely so this is testable
85
+ * without a live MCP — same DI shape as chooseChangeId's injected `mint` /
86
+ * resolveDeveloperSession's injected `fetchImpl` elsewhere in this CLI.
87
+ * SCOPED TO THE CHECKOUT'S OWN REMOTE: `expectedHost` (git's requested host,
88
+ * from the credential-helper request) is checked against the host of this
89
+ * checkout's `origin` remote before a credential is ever minted or returned
90
+ * — a `get` for any OTHER host returns null (silent fail), never handing the
91
+ * tenant's forge token to a host this checkout doesn't itself push to. This
92
+ * matters because `credential.helper` is invoked per-URL by git, and a
93
+ * globally-scoped helper (or a checkout with a submodule / unrelated remote)
94
+ * must not become a way to exfiltrate the token to an arbitrary host.
95
+ * @param {{
96
+ * env?: NodeJS.ProcessEnv, cwd?: string, expectedHost?: string,
97
+ * createClient?: typeof createMcpClient,
98
+ * establish?: typeof establishSession,
99
+ * checkout?: typeof checkoutTenant,
100
+ * }} [opts]
101
+ * @returns {Promise<{username:string,password:string}|null>}
102
+ */
103
+ export async function mintOrCacheCredential({
104
+ env = process.env, cwd = process.cwd(), expectedHost = null,
105
+ createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
106
+ } = {}) {
107
+ const ctx = detectContext(cwd);
108
+ if (ctx.mode !== "checkout" || !ctx.tenant) return null;
109
+
110
+ const git = (cargs) =>
111
+ execFileSync("git", ["-C", ctx.workspacePath, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
112
+ let originUrl = null;
113
+ let repoName = null;
114
+ try {
115
+ originUrl = git(["remote", "get-url", "origin"]).trim();
116
+ repoName = repoNameFromRemote(originUrl);
117
+ } catch {
118
+ /* fall through — tagFromRepoName degrades to "main" on a null repo name */
119
+ }
120
+ if (expectedHost && !hostMatches(originUrl, expectedHost)) return null;
121
+ const tag = tagFromRepoName(repoName, ctx.tenant);
122
+
123
+ const cachePath = credentialCachePath(ctx.tenant, tag, env);
124
+ const cached = readCachedCredential(cachePath);
125
+ if (cached) return { username: cached.username, password: cached.password };
126
+
127
+ const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
128
+ const client = createClient(baseUrl);
129
+ try {
130
+ await establish(client, { env });
131
+ } catch {
132
+ return null; // not signed in (or session unrefreshable) — nothing this helper can do
133
+ }
134
+ try {
135
+ const res = await checkout(client, { tenant: ctx.tenant, tag, cloneDir: null });
136
+ const cred = splitAuthedRemote(res.gitRemote || "");
137
+ if (!cred) return null;
138
+ const fresh = { username: cred.username, password: cred.token };
139
+ writeCachedCredential(cachePath, fresh);
140
+ return fresh;
141
+ } catch {
142
+ return null; // MCP unreachable / tenant_checkout refused — say nothing, exit non-zero
143
+ }
144
+ }
145
+
146
+ const USAGE = `tot git-credential — git credential-helper protocol over your \`tot login\` session
147
+
148
+ Configured automatically by \`tot clone\` (credential.helper = !tot git-credential)
149
+ in every checkout it creates — you should never need to run this by hand.
150
+ See \`git help gitcredentials\` for the protocol this implements.`;
151
+
152
+ /**
153
+ * @param {string[]} argv argv[0] is git's operation: get|store|erase
154
+ * @param {any} _ctx unused — this command derives its OWN context from cwd
155
+ * (mintOrCacheCredential), since git invokes it with the credentialed
156
+ * repo's directory as cwd, which may differ from wherever `tot` itself
157
+ * was dispatched from.
158
+ * @param {{ env?: NodeJS.ProcessEnv, cwd?: string, readStdin?: typeof readStdin }
159
+ * & Parameters<typeof mintOrCacheCredential>[0]} [opts]
160
+ */
161
+ export async function run(argv, _ctx, opts = {}) {
162
+ const { env = process.env, readStdin: read = readStdin } = opts;
163
+ const op = argv[0];
164
+ if (!op || op === "--help" || op === "-h") {
165
+ console.log(USAGE);
166
+ return op ? 0 : 2;
167
+ }
168
+ // git always writes a request to stdin, even for store/erase — drain it either way
169
+ // so the subprocess exits cleanly instead of leaving git's write blocked on a full pipe.
170
+ const request = parseCredentialInput(read());
171
+
172
+ if (op !== "get") return 0; // store/erase: stateless, nothing to persist or drop.
173
+
174
+ const cred = await mintOrCacheCredential({ ...opts, expectedHost: request.host });
175
+ if (!cred) return 1; // silent — let git fall through to its next helper / its own prompt.
176
+ process.stdout.write(formatCredentialOutput({
177
+ protocol: request.protocol, host: request.host, username: cred.username, password: cred.password,
178
+ }));
179
+ return 0;
180
+ }
@@ -0,0 +1,482 @@
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
+ }
200
+ if (action === "connect") {
201
+ lines.push(
202
+ "",
203
+ readiness.ready
204
+ ? " All preconditions green — this cutover is permitted."
205
+ : " Not ready — the failing preconditions above block the cutover.",
206
+ );
207
+ }
208
+ return lines;
209
+ }
210
+
211
+ // ─── Status poll ───────────────────────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Poll `GET /api/domain/status` until the in-flight run reaches a terminal state
215
+ * (succeeded|failed) or the attempts budget runs out. The dispatch is async — CI
216
+ * runs then POSTs the HMAC callback — so this CONFIRMS the real outcome instead of
217
+ * assuming it from the dispatch. Injectable delay/attempts. Returns the last
218
+ * normalised run status (may still be "dispatched" if CI is slow — reported honestly).
219
+ * @param {{ get:(path:string)=>Promise<any> }} http
220
+ */
221
+ export async function pollRunTerminal(http, { attempts = 8, delayMs = 2000, sleep } = {}) {
222
+ const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
223
+ let last = null;
224
+ for (let i = 0; i < attempts; i++) {
225
+ last = normalizeRunStatus(await http.get("/api/domain/status"));
226
+ if (last.status === "succeeded" || last.status === "failed") return last;
227
+ if (i < attempts - 1) await wait(delayMs);
228
+ }
229
+ return last;
230
+ }
231
+
232
+ // ─── Orchestration ───────────────────────────────────────────────────────────────
233
+
234
+ /**
235
+ * The go-live flow over an HTTP client — read readiness, render, gate, confirm,
236
+ * dispatch, poll, report. Split out from `run` so it's driven in tests with a mock
237
+ * `{ get, post }` HTTP client + injected confirm/interactive, no network / TTY /
238
+ * live DNS.
239
+ *
240
+ * @param {{ get:(path:string)=>Promise<any>, post:(path:string,body:any)=>Promise<any> }} http
241
+ * @param {{ appDomain:string, action:"connect"|"rollback", rehearsal:boolean, noOpen?:boolean }} params
242
+ * @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
243
+ * poll?:typeof pollRunTerminal, openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
244
+ * @returns {Promise<number>} process exit code
245
+ */
246
+ export async function runGoLive(http, { appDomain, action, rehearsal, noOpen }, deps = {}) {
247
+ const interactive = deps.interactive || isInteractive;
248
+ const confirm = deps.confirm || promptYesNo;
249
+ const poll = deps.poll || pollRunTerminal;
250
+
251
+ // 1. Read the server-authoritative readiness verdict + ALWAYS itemize it.
252
+ let readiness;
253
+ try {
254
+ readiness = normalizeReadiness(await http.get("/api/domain/readiness"));
255
+ } catch (e) {
256
+ console.error(
257
+ fail(
258
+ `couldn't read apex readiness: ${String(e?.message || e)}`,
259
+ "check your connection and that the operator token/owner are set, then re-run",
260
+ ),
261
+ );
262
+ return 1;
263
+ }
264
+ const domain = appDomain || readiness.appDomain;
265
+ for (const line of renderReadiness({ appDomain: domain, readiness, action })) console.log(line);
266
+
267
+ // 2. Gate — owner-only + (for connect) fail-closed on the readiness verdict.
268
+ const gate = goLiveReadinessGate(readiness, action);
269
+ if (gate.kind === "not-owner") {
270
+ console.error(fail(gate.blockers[0], "the admin Domain tab connects the apex from the owner's signed-in session"));
271
+ return 1;
272
+ }
273
+ if (gate.kind !== "ready") {
274
+ console.error(
275
+ fail(
276
+ `apex ${action} is not ready`,
277
+ "clear the failing preconditions above (e.g. run the dual-run, capture prior records), then re-run",
278
+ ),
279
+ );
280
+ return 1;
281
+ }
282
+
283
+ // 3. ALWAYS require one explicit y/N confirm; refuse in a NON-TTY.
284
+ if (!interactive()) {
285
+ console.error(
286
+ fail(
287
+ `\`tot go-live${action === "rollback" ? " --rollback" : ""}\` needs an interactive terminal to confirm the live change`,
288
+ "run it from a terminal (there is intentionally no --yes/--force)",
289
+ ),
290
+ );
291
+ return 2;
292
+ }
293
+ const verb = action === "rollback" ? "roll the apex DNS back" : rehearsal ? "REHEARSE the apex cutover" : "cut the apex over";
294
+ const proceed = await confirm(`\n ${cap(verb)} for ${domain}?`, false);
295
+ if (!proceed) {
296
+ console.log(" Cancelled — nothing changed.");
297
+ return 0;
298
+ }
299
+
300
+ // 4. Dispatch the SAME go-live CI action the admin button fires. The connect
301
+ // requires the typed domain confirmation server-side (confirmDomain).
302
+ let dispatch;
303
+ try {
304
+ dispatch = normalizeDispatch(
305
+ await http.post("/api/domain/dispatch", {
306
+ action,
307
+ rehearsal,
308
+ ...(action === "connect" ? { confirmDomain: domain } : {}),
309
+ }),
310
+ );
311
+ } catch (e) {
312
+ console.error(
313
+ fail(
314
+ `the go-live dispatch was refused: ${String(e?.message || e)}`,
315
+ "the server gates the cutover (owner session + readiness); resolve the reason it reported, then re-run",
316
+ ),
317
+ );
318
+ return 1;
319
+ }
320
+ if (!dispatch.dispatched) {
321
+ console.error(
322
+ fail(
323
+ dispatch.error || "the go-live dispatch did not fire",
324
+ "the server refused the cutover — recheck `tot go-live` readiness and that you're the signed-in owner",
325
+ ),
326
+ );
327
+ return 1;
328
+ }
329
+
330
+ // 5. Poll for the REAL terminal state — never claim "cut over" before the CI
331
+ // callback confirms it.
332
+ const progress = deps.progress === false ? null : startProgress(action === "rollback" ? "rolling back…" : "cutting over…");
333
+ let status;
334
+ try {
335
+ status = await poll(http);
336
+ } finally {
337
+ progress?.stop();
338
+ }
339
+ return reportGoLive({ status, dispatch, action, rehearsal, domain, noOpen, openUrl: deps.openUrl });
340
+ }
341
+
342
+ /** Report the go-live outcome honestly + always surface the one-line revert. */
343
+ function reportGoLive({ status, dispatch, action, rehearsal, domain, noOpen, openUrl }) {
344
+ const runUrl = status?.runUrl || null;
345
+ const revertHint = " Revert (one line): tot go-live --rollback";
346
+
347
+ if (status?.status === "succeeded") {
348
+ if (rehearsal) {
349
+ console.log(`\n ✓ rehearsal succeeded for ${domain} — DNS state unchanged (dry run).`);
350
+ } else if (action === "rollback") {
351
+ console.log(`\n ✓ rolled the apex DNS back for ${domain} to the captured prior records.`);
352
+ } else {
353
+ console.log(`\n ✓ apex cut over for ${domain} — the storefront is now live on the apex.`);
354
+ const url = `https://${domain}`;
355
+ console.log(` Live: ${url}`);
356
+ if (!noOpen && openUrl && openUrl(url)) console.log(" (opened in your browser)");
357
+ }
358
+ if (action !== "rollback") console.log(revertHint);
359
+ return 0;
360
+ }
361
+
362
+ if (status?.status === "failed") {
363
+ console.error(
364
+ fail(
365
+ `the apex ${action} run failed${status.error ? `: ${status.error}` : ""}`,
366
+ action === "rollback"
367
+ ? "check the CI run, then retry"
368
+ : "the DNS was not changed if the run failed before cutover; check the CI run, then retry — or `tot go-live --rollback`",
369
+ ),
370
+ );
371
+ if (runUrl) console.error(` CI run: ${runUrl}`);
372
+ return 1;
373
+ }
374
+
375
+ // Dispatched but not yet terminal — CI is still running. Report honestly.
376
+ console.log(`\n ~ ${action} dispatched for ${domain}${dispatch.runId ? ` (run ${dispatch.runId})` : ""}; it's running now.`);
377
+ if (runUrl) console.log(` Track it: ${runUrl}`);
378
+ console.log(" Re-run `tot go-live` (or watch the admin Domain tab) for the terminal state.");
379
+ if (action !== "rollback") console.log(revertHint);
380
+ return 0;
381
+ }
382
+
383
+ function cap(s) {
384
+ return s.charAt(0).toUpperCase() + s.slice(1);
385
+ }
386
+
387
+ /**
388
+ * Build an HTTP client over the storefront apex-domain endpoints. Auth mirrors the
389
+ * headless operator trust boundary the storefront's `resolveOwnerSession` Path 2
390
+ * accepts: a Bearer operator secret + `X-Tot-Owner`, plus the `x-tot-capability`
391
+ * ship floor. Throws on a non-2xx with the server's error message (so the caller
392
+ * surfaces the real refusal). `fetchImpl` is injectable for tests.
393
+ * @param {string} base
394
+ * @param {{ token:string|undefined, owner:string, capability?:string, fetchImpl?:typeof fetch }} auth
395
+ */
396
+ export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", fetchImpl } = {}) {
397
+ const root = base.replace(/\/+$/, "");
398
+ const doFetch = fetchImpl || fetch;
399
+ const headers = () => ({
400
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
401
+ ...(owner ? { "x-tot-owner": owner } : {}),
402
+ "x-tot-capability": capability,
403
+ });
404
+ const parse = async (res) => {
405
+ const text = await res.text();
406
+ let body = null;
407
+ try {
408
+ body = text ? JSON.parse(text) : null;
409
+ } catch {
410
+ body = null;
411
+ }
412
+ if (!res.ok) {
413
+ const msg = (body && typeof body === "object" && typeof body.error === "string" && body.error) ||
414
+ `HTTP ${res.status}`;
415
+ throw new Error(msg);
416
+ }
417
+ return body;
418
+ };
419
+ return {
420
+ async get(path) {
421
+ return parse(await doFetch(`${root}${path}`, { method: "GET", headers: headers() }));
422
+ },
423
+ async post(path, body) {
424
+ return parse(
425
+ await doFetch(`${root}${path}`, {
426
+ method: "POST",
427
+ headers: { ...headers(), "content-type": "application/json" },
428
+ body: JSON.stringify(body ?? {}),
429
+ }),
430
+ );
431
+ },
432
+ };
433
+ }
434
+
435
+ /**
436
+ * @param {string[]} argv
437
+ * @param {any} ctx
438
+ */
439
+ export async function run(argv, ctx) {
440
+ const env = process.env;
441
+ const args = parseGoLiveArgs(argv);
442
+ if (args.help) {
443
+ console.log(USAGE);
444
+ return 0;
445
+ }
446
+ if (ctx.mode !== "checkout") {
447
+ console.error(
448
+ fail(
449
+ "`tot go-live` runs from inside a tenant checkout",
450
+ "tot clone <tenant> <dir> (then `cd` in, and `tot go-live`)",
451
+ ),
452
+ );
453
+ return 2;
454
+ }
455
+
456
+ // The owner/appDomain to act on, and the storefront base URL. Owner defaults to
457
+ // the checkout's tenant (owner == appDomain in this platform); the base URL
458
+ // defaults to https://<owner> unless overridden.
459
+ const owner = args.owner || env.TOT_STOREFRONT_OWNER || ctx.tenant;
460
+ if (!owner) {
461
+ console.error(
462
+ fail(
463
+ "couldn't resolve the store owner/appDomain for this checkout",
464
+ "pass --owner <appDomain> or set TOT_STOREFRONT_OWNER",
465
+ ),
466
+ );
467
+ return 1;
468
+ }
469
+ const base = args.url || env.TOT_STOREFRONT_URL || `https://${owner}`;
470
+ const token = env.TOT_STOREFRONT_OPERATOR_TOKEN || env.PREVIEW_RECONCILE_SECRET;
471
+
472
+ // The storefront apex-domain endpoints authenticate via the operator token +
473
+ // X-Tot-Owner (resolveOwnerSession Path 2), NOT the MCP OAuth session — so there
474
+ // is no MCP sign-in step here. Owner INTENT is enforced by the explicit confirm
475
+ // below and, authoritatively, by the server's owner-only readiness/dispatch gate.
476
+ const http = createStorefrontHttp(base, { token, owner });
477
+ return await runGoLive(
478
+ http,
479
+ { appDomain: owner, action: args.action, rehearsal: args.rehearsal, noOpen: args.noOpen },
480
+ { openUrl: (u) => openBrowser(u) },
481
+ );
482
+ }