@tokenoftrust/cli 1.4.0-rc.10 → 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.
- package/README.md +7 -4
- package/bin/tot.mjs +35 -2
- package/package.json +1 -1
- package/src/candidate-state.mjs +56 -16
- package/src/commands/accept.mjs +247 -0
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/pr.mjs +21 -7
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +9 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/rollback.mjs +401 -0
- package/src/commands/ship.mjs +990 -28
- package/src/commands/submit.mjs +259 -28
- package/src/plan.mjs +160 -0
- package/src/sample.mjs +27 -1
|
@@ -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
|
+
}
|
package/src/commands/pr.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { execFileSync } from "node:child_process";
|
|
|
21
21
|
import { createMcpClient } from "../mcp.mjs";
|
|
22
22
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
23
23
|
import { fail } from "../errors.mjs";
|
|
24
|
-
import { repoNameFromRemote } from "./submit.mjs";
|
|
24
|
+
import { repoNameFromRemote, currentBranch } from "./submit.mjs";
|
|
25
25
|
import {
|
|
26
26
|
defaultCandidateStatePath,
|
|
27
27
|
readActiveChangeId,
|
|
@@ -79,11 +79,23 @@ export function matchCandidate(candidates, target) {
|
|
|
79
79
|
return candidates.find((c) => c.changeId === target) ?? null;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* One-line candidate summary for `tot pr list` — surfaces branch ↔ PR# ↔ preview
|
|
84
|
+
* URL so a dev sees, at a glance, which git branch each candidate belongs to (u4 —
|
|
85
|
+
* branch-bound candidates) and where its preview lives. Prefers the candidate's
|
|
86
|
+
* `previewUrl`, falling back to the PR `url`. `active` marks the one THIS checkout's
|
|
87
|
+
* branch resolves to. Pure — unit-tested.
|
|
88
|
+
* @param {{prNumber?:number|null, branch?:string|null, changeId:string, state?:string|null,
|
|
89
|
+
* previewUrl?:string|null, url?:string|null}} c
|
|
90
|
+
* @param {{ active?: boolean }} [opts]
|
|
91
|
+
*/
|
|
92
|
+
export function formatCandidateLine(c, { active = false } = {}) {
|
|
84
93
|
const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
|
|
85
|
-
const
|
|
86
|
-
|
|
94
|
+
const branch = c.branch ? c.branch : "(no branch)";
|
|
95
|
+
const previewUrl = c.previewUrl || c.url || null;
|
|
96
|
+
const urlPart = previewUrl ? ` ${previewUrl}` : "";
|
|
97
|
+
const activePart = active ? " ← active" : "";
|
|
98
|
+
return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
/** @param {string[]} argv @param {any} ctx */
|
|
@@ -128,7 +140,9 @@ export async function run(argv, ctx) {
|
|
|
128
140
|
|
|
129
141
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
130
142
|
const statePath = defaultCandidateStatePath(env);
|
|
131
|
-
|
|
143
|
+
// Branch-bound (u4): the active-pointer namespace is scoped to the current git
|
|
144
|
+
// branch, so the "← active" marker reflects THIS branch's candidate.
|
|
145
|
+
const scope = { mcpUrl: baseUrl, repo, branch: currentBranch(gitSafe) };
|
|
132
146
|
const client = createMcpClient(baseUrl);
|
|
133
147
|
try {
|
|
134
148
|
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
@@ -146,7 +160,7 @@ export async function run(argv, ctx) {
|
|
|
146
160
|
const active = readActiveChangeId(statePath, scope);
|
|
147
161
|
console.log(`Open candidate PRs for ${repo}:`);
|
|
148
162
|
for (const c of candidates) {
|
|
149
|
-
console.log(
|
|
163
|
+
console.log(formatCandidateLine(c, { active: !!active && c.changeId === active }));
|
|
150
164
|
}
|
|
151
165
|
return 0;
|
|
152
166
|
}
|