@tokenoftrust/cli 4.11.1 → 4.11.2
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/package.json +1 -1
- package/src/commands/go-live.mjs +123 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "4.11.
|
|
3
|
+
"version": "4.11.2",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/commands/go-live.mjs
CHANGED
|
@@ -38,13 +38,15 @@ import { isInteractive, promptYesNo } from "../prompt.mjs";
|
|
|
38
38
|
import { startProgress } from "../progress.mjs";
|
|
39
39
|
import { openBrowser } from "../open.mjs";
|
|
40
40
|
import { formatImageTypes, normalizeImageTypes } from "../image-census.mjs";
|
|
41
|
+
import { DEFAULT_STOREFRONT_ORIGIN, describeIdentity, resolveViewerTransport } from "../viewer-session.mjs";
|
|
42
|
+
import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
|
|
41
43
|
|
|
42
44
|
const USAGE = `tot go-live — cut the apex domain over to the storefront
|
|
43
45
|
|
|
44
46
|
tot go-live show apex readiness, then (if ready) connect the apex
|
|
45
47
|
tot go-live --rehearsal dispatch a DRY-RUN cutover (never moves DNS state)
|
|
46
48
|
tot go-live --rollback restore the captured prior DNS records (the revert)
|
|
47
|
-
tot go-live --url <
|
|
49
|
+
tot go-live --url <origin> storefront origin (default: env TOT_STOREFRONT_URL / ${DEFAULT_STOREFRONT_ORIGIN})
|
|
48
50
|
tot go-live --owner <domain> owner/appDomain to act on (default: env TOT_STOREFRONT_OWNER / checkout tenant)
|
|
49
51
|
tot go-live --no-open don't open the store in your browser afterward
|
|
50
52
|
|
|
@@ -52,7 +54,11 @@ const USAGE = `tot go-live — cut the apex domain over to the storefront
|
|
|
52
54
|
same server readiness gate. It ALWAYS shows you the readiness itemization and
|
|
53
55
|
asks for a single y/N confirmation first; a CONNECT is refused unless the server
|
|
54
56
|
reports ready. There is no --yes/--force, and it refuses to run without an
|
|
55
|
-
interactive terminal. Reversible: \`tot go-live --rollback
|
|
57
|
+
interactive terminal. Reversible: \`tot go-live --rollback\`.
|
|
58
|
+
|
|
59
|
+
Authentication: your own \`tot login\` session. The store decides what you may
|
|
60
|
+
do: the owner reads readiness and cuts over; others see what the store admits.
|
|
61
|
+
Operators/CI may instead set TOT_STOREFRONT_OPERATOR_TOKEN or PREVIEW_RECONCILE_SECRET.`;
|
|
56
62
|
|
|
57
63
|
/** Parse `tot go-live` argv. Pure. Deliberately NO --yes/--force (see the header). */
|
|
58
64
|
export function parseGoLiveArgs(argv) {
|
|
@@ -251,7 +257,9 @@ export async function pollRunTerminal(http, { attempts = 8, delayMs = 2000, slee
|
|
|
251
257
|
* @param {{ appDomain:string, action:"connect"|"rollback", rehearsal:boolean, noOpen?:boolean,
|
|
252
258
|
* dnsSetup?: { domainConnectDeclared?: boolean, ciRoleCanChangeZone?: boolean, nameserversAtRegistrar?: boolean } }} params
|
|
253
259
|
* @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
|
|
254
|
-
* poll?:typeof pollRunTerminal, openUrl?:(u:string)=>boolean, progress?:boolean
|
|
260
|
+
* poll?:typeof pollRunTerminal, openUrl?:(u:string)=>boolean, progress?:boolean,
|
|
261
|
+
* base?:string|null, identity?:{ account?:string|null, mcp?:string|null }|null }} [deps]
|
|
262
|
+
* `base`/`identity` only name where and as whom a failed readiness read was made.
|
|
255
263
|
* @returns {Promise<number>} process exit code
|
|
256
264
|
*/
|
|
257
265
|
export async function runGoLive(http, { appDomain, action, rehearsal, noOpen, dnsSetup }, deps = {}) {
|
|
@@ -264,12 +272,8 @@ export async function runGoLive(http, { appDomain, action, rehearsal, noOpen, dn
|
|
|
264
272
|
try {
|
|
265
273
|
readiness = normalizeReadiness(await http.get("/api/domain/readiness"));
|
|
266
274
|
} catch (e) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
`couldn't read apex readiness: ${String(e?.message || e)}`,
|
|
270
|
-
"check your connection and that the operator token/owner are set, then re-run",
|
|
271
|
-
),
|
|
272
|
-
);
|
|
275
|
+
const { message, hint } = describeReadinessFailure(e, { base: deps.base ?? null, identity: deps.identity ?? null });
|
|
276
|
+
console.error(fail(message, hint));
|
|
273
277
|
return 1;
|
|
274
278
|
}
|
|
275
279
|
const domain = appDomain || readiness.appDomain;
|
|
@@ -459,21 +463,99 @@ function cap(s) {
|
|
|
459
463
|
}
|
|
460
464
|
|
|
461
465
|
/**
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
466
|
+
* Why the readiness read failed, as one `{ message, hint }`. Pure. Reads the HTTP
|
|
467
|
+
* status and server error code `createStorefrontHttp` attaches; an error without a
|
|
468
|
+
* status never reached a server, and only that case is a connection problem.
|
|
469
|
+
* @param {any} e
|
|
470
|
+
* @param {{ base?: string|null, identity?: { account?: string|null, mcp?: string|null }|null }} [ctx]
|
|
471
|
+
* @returns {{ message: string, hint: string }}
|
|
472
|
+
*/
|
|
473
|
+
export function describeReadinessFailure(e, { base = null, identity = null } = {}) {
|
|
474
|
+
const status = typeof e?.status === "number" ? e.status : null;
|
|
475
|
+
const code = typeof e?.code === "string" ? e.code : null;
|
|
476
|
+
const where = base ? ` at ${base}` : "";
|
|
477
|
+
if (status === 404) {
|
|
478
|
+
return {
|
|
479
|
+
message: `couldn't read apex readiness${where}: HTTP 404 — that host is not serving this store from the storefront yet (before cutover the domain still points at the old platform)`,
|
|
480
|
+
hint: `drop --url / TOT_STOREFRONT_URL to use the shared storefront, or pass --url ${DEFAULT_STOREFRONT_ORIGIN}`,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
if (status === 401 || status === 403 || code === "store-not-public") {
|
|
484
|
+
const who = describeIdentity(identity);
|
|
485
|
+
const missing = missingGrantsOf(e?.body);
|
|
486
|
+
return {
|
|
487
|
+
message: `the store refused the readiness read${where} (${code || `HTTP ${status}`})${who ? ` for ${who}` : ""}`,
|
|
488
|
+
// The server names what is missing; with nothing named, a refusal here means the
|
|
489
|
+
// caller is not signed in as someone this store admits.
|
|
490
|
+
hint: missing.length
|
|
491
|
+
? refusalHint(status ?? 401, missing)
|
|
492
|
+
: "run `tot login` as the store owner (or a developer invited to the store), then re-run",
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (status !== null) {
|
|
496
|
+
return {
|
|
497
|
+
message: `couldn't read apex readiness${where}: ${code || `HTTP ${status}`}`,
|
|
498
|
+
hint: "retry shortly; if it persists, report it with `tot feedback`",
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
message: `couldn't reach the storefront${where}: ${String(e?.message || e)}`,
|
|
503
|
+
hint: "check your connection, then re-run",
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The storefront origin go-live talks to. Pure. The shared storefront serves every
|
|
509
|
+
* tenant before and after cutover, so it is the default; the apex is never assumed.
|
|
510
|
+
* A `--url` already scoped to this tenant (`<origin>/<owner>`) names the same store.
|
|
511
|
+
* @param {{ url?: string|null, envUrl?: string|null, owner: string }} p
|
|
512
|
+
*/
|
|
513
|
+
export function resolveGoLiveOrigin({ url = null, envUrl = null, owner }) {
|
|
514
|
+
const raw = String(url || envUrl || DEFAULT_STOREFRONT_ORIGIN).trim().replace(/\/+$/, "");
|
|
515
|
+
const suffix = `/${String(owner || "").trim().toLowerCase()}`;
|
|
516
|
+
return suffix.length > 1 && raw.toLowerCase().endsWith(suffix) ? raw.slice(0, -suffix.length) : raw;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The transport for the apex-domain endpoints. An operator secret (operators/CI)
|
|
521
|
+
* keeps the Bearer + `X-Tot-Owner` route on the origin; without one, the caller's own
|
|
522
|
+
* `tot login` is exchanged for a storefront viewer session on the tenant's base, the
|
|
523
|
+
* same transport `tot ship` / `tot accept` use. The server's admission decides what
|
|
524
|
+
* either may do; this only carries the identity.
|
|
525
|
+
* @param {{ origin: string, owner: string, token?: string|null, env?: NodeJS.ProcessEnv,
|
|
526
|
+
* fetchImpl?: typeof fetch, resolveViewer?: typeof resolveViewerTransport }} p
|
|
527
|
+
* @returns {Promise<{ ok: true, base: string, token: string|null, headers: Record<string,string>,
|
|
528
|
+
* identity: { account: string|null, mcp: string|null }|null } | { ok: false, message: string, hint: string }>}
|
|
529
|
+
*/
|
|
530
|
+
export async function resolveGoLiveTransport({
|
|
531
|
+
origin, owner, token = null, env = process.env, fetchImpl = fetch, resolveViewer = resolveViewerTransport,
|
|
532
|
+
}) {
|
|
533
|
+
if (token) return { ok: true, base: origin, token, headers: {}, identity: null };
|
|
534
|
+
const viewer = await resolveViewer({ tenant: owner, env, fetchImpl, storefrontUrl: origin });
|
|
535
|
+
if (!viewer.ok) return { ok: false, message: viewer.message, hint: viewer.hint };
|
|
536
|
+
return { ok: true, base: viewer.base, token: null, headers: viewer.authHeaders, identity: viewer.identity };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Build an HTTP client over the storefront apex-domain endpoints. With `token`, it
|
|
541
|
+
* sends the headless operator trust boundary `resolveOwnerSession` Path 2 accepts:
|
|
542
|
+
* a Bearer operator secret + `X-Tot-Owner`. `headers` carries a viewer-session
|
|
543
|
+
* cookie instead. Every request carries the `x-tot-capability` ship floor. Throws on
|
|
544
|
+
* a non-2xx with the server's error message, plus `status` and `code` (the server's
|
|
545
|
+
* error code), so the caller can tell a refusal from a missing route.
|
|
546
|
+
* `fetchImpl` is injectable for tests.
|
|
467
547
|
* @param {string} base
|
|
468
|
-
* @param {{ token?:string, owner?:string, capability?:string,
|
|
548
|
+
* @param {{ token?:string|null, owner?:string, capability?:string, headers?:Record<string,string>,
|
|
549
|
+
* fetchImpl?:typeof fetch }} [auth]
|
|
469
550
|
*/
|
|
470
|
-
export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", fetchImpl } = {}) {
|
|
551
|
+
export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", headers: extra = {}, fetchImpl } = {}) {
|
|
471
552
|
const root = base.replace(/\/+$/, "");
|
|
472
553
|
const doFetch = fetchImpl || fetch;
|
|
473
554
|
const headers = () => ({
|
|
474
555
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
475
|
-
...(owner ? { "x-tot-owner": owner } : {}),
|
|
556
|
+
...(token && owner ? { "x-tot-owner": owner } : {}),
|
|
476
557
|
"x-tot-capability": capability,
|
|
558
|
+
...extra,
|
|
477
559
|
});
|
|
478
560
|
const parse = async (res) => {
|
|
479
561
|
const text = await res.text();
|
|
@@ -484,9 +566,12 @@ export function createStorefrontHttp(base, { token, owner, capability = "ship-on
|
|
|
484
566
|
body = null;
|
|
485
567
|
}
|
|
486
568
|
if (!res.ok) {
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
569
|
+
const code = (body && typeof body === "object" && typeof body.error === "string" && body.error) || null;
|
|
570
|
+
const err = /** @type {Error & { status?: number, code?: string|null, body?: any }} */ (new Error(code || `HTTP ${res.status}`));
|
|
571
|
+
err.status = res.status;
|
|
572
|
+
err.code = code;
|
|
573
|
+
err.body = body;
|
|
574
|
+
throw err;
|
|
490
575
|
}
|
|
491
576
|
return body;
|
|
492
577
|
};
|
|
@@ -527,9 +612,8 @@ export async function run(argv, ctx) {
|
|
|
527
612
|
return 2;
|
|
528
613
|
}
|
|
529
614
|
|
|
530
|
-
// The owner/appDomain to act on
|
|
531
|
-
//
|
|
532
|
-
// defaults to https://<owner> unless overridden.
|
|
615
|
+
// The owner/appDomain to act on. Owner defaults to the checkout's tenant
|
|
616
|
+
// (owner == appDomain in this platform).
|
|
533
617
|
const owner = args.owner || env.TOT_STOREFRONT_OWNER || ctx.tenant;
|
|
534
618
|
if (!owner) {
|
|
535
619
|
console.error(
|
|
@@ -540,17 +624,24 @@ export async function run(argv, ctx) {
|
|
|
540
624
|
);
|
|
541
625
|
return 1;
|
|
542
626
|
}
|
|
543
|
-
const
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
627
|
+
const origin = resolveGoLiveOrigin({ url: args.url, envUrl: env.TOT_STOREFRONT_URL, owner });
|
|
628
|
+
const transport = await resolveGoLiveTransport({
|
|
629
|
+
origin,
|
|
630
|
+
owner,
|
|
631
|
+
token: env.TOT_STOREFRONT_OPERATOR_TOKEN || env.PREVIEW_RECONCILE_SECRET || null,
|
|
632
|
+
env,
|
|
633
|
+
});
|
|
634
|
+
if (!transport.ok) {
|
|
635
|
+
console.error(fail(transport.message, transport.hint));
|
|
636
|
+
return 2;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Owner INTENT is enforced by the explicit confirm in runGoLive and,
|
|
640
|
+
// authoritatively, by the server's owner-only readiness/dispatch gate.
|
|
641
|
+
const http = createStorefrontHttp(transport.base, { token: transport.token, owner, headers: transport.headers });
|
|
551
642
|
return await runGoLive(
|
|
552
643
|
http,
|
|
553
644
|
{ appDomain: owner, action: /** @type {any} */ (args.action), rehearsal: args.rehearsal, noOpen: args.noOpen },
|
|
554
|
-
{ openUrl: (u) => openBrowser(u) },
|
|
645
|
+
{ openUrl: (u) => openBrowser(u), base: transport.base, identity: transport.identity },
|
|
555
646
|
);
|
|
556
647
|
}
|