@tokenoftrust/cli 4.11.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "4.11.0",
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",
@@ -37,13 +37,16 @@ import { fail } from "../errors.mjs";
37
37
  import { isInteractive, promptYesNo } from "../prompt.mjs";
38
38
  import { startProgress } from "../progress.mjs";
39
39
  import { openBrowser } from "../open.mjs";
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";
40
43
 
41
44
  const USAGE = `tot go-live — cut the apex domain over to the storefront
42
45
 
43
46
  tot go-live show apex readiness, then (if ready) connect the apex
44
47
  tot go-live --rehearsal dispatch a DRY-RUN cutover (never moves DNS state)
45
48
  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>)
49
+ tot go-live --url <origin> storefront origin (default: env TOT_STOREFRONT_URL / ${DEFAULT_STOREFRONT_ORIGIN})
47
50
  tot go-live --owner <domain> owner/appDomain to act on (default: env TOT_STOREFRONT_OWNER / checkout tenant)
48
51
  tot go-live --no-open don't open the store in your browser afterward
49
52
 
@@ -51,7 +54,11 @@ const USAGE = `tot go-live — cut the apex domain over to the storefront
51
54
  same server readiness gate. It ALWAYS shows you the readiness itemization and
52
55
  asks for a single y/N confirmation first; a CONNECT is refused unless the server
53
56
  reports ready. There is no --yes/--force, and it refuses to run without an
54
- 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.`;
55
62
 
56
63
  /** Parse `tot go-live` argv. Pure. Deliberately NO --yes/--force (see the header). */
57
64
  export function parseGoLiveArgs(argv) {
@@ -111,6 +118,7 @@ export function normalizeReadiness(r) {
111
118
  ? rd.blockedReasons.filter((s) => typeof s === "string")
112
119
  : checks.filter((c) => !c.ok).map((c) => c.detail),
113
120
  appDomain: typeof o.appDomain === "string" ? o.appDomain : null,
121
+ imageTypes: normalizeImageTypes(o.imageTypes),
114
122
  domainState: rd && o.domain && typeof o.domain === "object" ? o.domain : null,
115
123
  };
116
124
  }
@@ -201,6 +209,10 @@ export function renderReadiness({ appDomain, readiness, action }) {
201
209
  if (!c.ok && c.remedy) lines.push(` → ${c.remedy}`);
202
210
  }
203
211
  if (action === "connect") {
212
+ lines.push(
213
+ "",
214
+ ` Uncaptured images by type: ${readiness.imageTypes ? formatImageTypes(readiness.imageTypes) : "not recorded for the live bundle"}`,
215
+ );
204
216
  lines.push(
205
217
  "",
206
218
  readiness.ready
@@ -245,7 +257,9 @@ export async function pollRunTerminal(http, { attempts = 8, delayMs = 2000, slee
245
257
  * @param {{ appDomain:string, action:"connect"|"rollback", rehearsal:boolean, noOpen?:boolean,
246
258
  * dnsSetup?: { domainConnectDeclared?: boolean, ciRoleCanChangeZone?: boolean, nameserversAtRegistrar?: boolean } }} params
247
259
  * @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
248
- * poll?:typeof pollRunTerminal, openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
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.
249
263
  * @returns {Promise<number>} process exit code
250
264
  */
251
265
  export async function runGoLive(http, { appDomain, action, rehearsal, noOpen, dnsSetup }, deps = {}) {
@@ -258,12 +272,8 @@ export async function runGoLive(http, { appDomain, action, rehearsal, noOpen, dn
258
272
  try {
259
273
  readiness = normalizeReadiness(await http.get("/api/domain/readiness"));
260
274
  } catch (e) {
261
- console.error(
262
- fail(
263
- `couldn't read apex readiness: ${String(e?.message || e)}`,
264
- "check your connection and that the operator token/owner are set, then re-run",
265
- ),
266
- );
275
+ const { message, hint } = describeReadinessFailure(e, { base: deps.base ?? null, identity: deps.identity ?? null });
276
+ console.error(fail(message, hint));
267
277
  return 1;
268
278
  }
269
279
  const domain = appDomain || readiness.appDomain;
@@ -453,21 +463,99 @@ function cap(s) {
453
463
  }
454
464
 
455
465
  /**
456
- * Build an HTTP client over the storefront apex-domain endpoints. Auth mirrors the
457
- * headless operator trust boundary the storefront's `resolveOwnerSession` Path 2
458
- * accepts: a Bearer operator secret + `X-Tot-Owner`, plus the `x-tot-capability`
459
- * ship floor. Throws on a non-2xx with the server's error message (so the caller
460
- * surfaces the real refusal). `fetchImpl` is injectable for tests.
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.
461
547
  * @param {string} base
462
- * @param {{ token?:string, owner?:string, capability?:string, fetchImpl?:typeof fetch }} [auth]
548
+ * @param {{ token?:string|null, owner?:string, capability?:string, headers?:Record<string,string>,
549
+ * fetchImpl?:typeof fetch }} [auth]
463
550
  */
464
- 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 } = {}) {
465
552
  const root = base.replace(/\/+$/, "");
466
553
  const doFetch = fetchImpl || fetch;
467
554
  const headers = () => ({
468
555
  ...(token ? { authorization: `Bearer ${token}` } : {}),
469
- ...(owner ? { "x-tot-owner": owner } : {}),
556
+ ...(token && owner ? { "x-tot-owner": owner } : {}),
470
557
  "x-tot-capability": capability,
558
+ ...extra,
471
559
  });
472
560
  const parse = async (res) => {
473
561
  const text = await res.text();
@@ -478,9 +566,12 @@ export function createStorefrontHttp(base, { token, owner, capability = "ship-on
478
566
  body = null;
479
567
  }
480
568
  if (!res.ok) {
481
- const msg = (body && typeof body === "object" && typeof body.error === "string" && body.error) ||
482
- `HTTP ${res.status}`;
483
- throw new Error(msg);
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;
484
575
  }
485
576
  return body;
486
577
  };
@@ -521,9 +612,8 @@ export async function run(argv, ctx) {
521
612
  return 2;
522
613
  }
523
614
 
524
- // The owner/appDomain to act on, and the storefront base URL. Owner defaults to
525
- // the checkout's tenant (owner == appDomain in this platform); the base URL
526
- // 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).
527
617
  const owner = args.owner || env.TOT_STOREFRONT_OWNER || ctx.tenant;
528
618
  if (!owner) {
529
619
  console.error(
@@ -534,17 +624,24 @@ export async function run(argv, ctx) {
534
624
  );
535
625
  return 1;
536
626
  }
537
- const base = args.url || env.TOT_STOREFRONT_URL || `https://${owner}`;
538
- const token = env.TOT_STOREFRONT_OPERATOR_TOKEN || env.PREVIEW_RECONCILE_SECRET;
539
-
540
- // The storefront apex-domain endpoints authenticate via the operator token +
541
- // X-Tot-Owner (resolveOwnerSession Path 2), NOT the MCP OAuth session — so there
542
- // is no MCP sign-in step here. Owner INTENT is enforced by the explicit confirm
543
- // below and, authoritatively, by the server's owner-only readiness/dispatch gate.
544
- const http = createStorefrontHttp(base, { token, owner });
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 });
545
642
  return await runGoLive(
546
643
  http,
547
644
  { appDomain: owner, action: /** @type {any} */ (args.action), rehearsal: args.rehearsal, noOpen: args.noOpen },
548
- { openUrl: (u) => openBrowser(u) },
645
+ { openUrl: (u) => openBrowser(u), base: transport.base, identity: transport.identity },
549
646
  );
550
647
  }
@@ -22,6 +22,7 @@ import { join, relative, resolve, sep } from "node:path";
22
22
  import { validateTenant, ERROR, WARN, printLoudAdvisories } from "../validate.mjs";
23
23
  import { validateVisualParityClosure } from "../visual-parity.mjs";
24
24
  import { fail } from "../errors.mjs";
25
+ import { countSourceImages, formatImageTypes } from "../image-census.mjs";
25
26
  import { tenantDirSegments } from "../tenant-dirs.mjs";
26
27
  import {
27
28
  describeOwner, readWorkspaceTopology, rootFilesOwnedByMounts, TOPOLOGY_FILE,
@@ -187,7 +188,7 @@ export function run(argv, ctx) {
187
188
  const { ok, findings } = result;
188
189
 
189
190
  if (args.json) {
190
- console.log(JSON.stringify({ ok, findings }, null, 2));
191
+ console.log(JSON.stringify({ ok, findings, imageCensus: countSourceImages(dir) }, null, 2));
191
192
  return ok ? 0 : 1;
192
193
  }
193
194
 
@@ -210,6 +211,9 @@ export function run(argv, ctx) {
210
211
  console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
211
212
  }
212
213
  if (findings.length === 0) console.log(" (no findings)");
214
+ // Informational, never a finding: the repo keeps source URLs and the publish captures them. A
215
+ // non-zero type is what `tot go-live` will still block on until a publish has captured it.
216
+ console.log(`\n Uncaptured images by type: ${formatImageTypes(countSourceImages(dir))}`);
213
217
  console.log(`\n${errors.length === 0 ? "✔" : "✖"} ${errors.length} error(s), ${warns.length} warning(s).\n`);
214
218
  return errors.length === 0 ? 0 : 1;
215
219
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The local image census behind `tot validate`, and the one-line format `tot go-live` shares.
3
+ *
4
+ * The repo keeps source URLs by design; the publish captures every image into the platform pool.
5
+ * This counts, without a network or a publish, the source-hosted image references capture will
6
+ * still have to pull, by type: catalog, blog, pages, other. It is an estimate of the same quantity
7
+ * the publish census measures (`scripts/publish/lib/captured-image-rewrite.mjs`); the type rule is
8
+ * the same and a test pins the two together. Dependency-free.
9
+ */
10
+ import { lstatSync, readdirSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ export const IMAGE_TYPES = ["catalog", "blog", "pages", "other"];
14
+ const TEXT = /\.(?:html?|css|json)$/i;
15
+ const MAX_TEXT = 1_500_000;
16
+ const PAGES_PREFIXES = ["content/pages/", "content/pages-html/"];
17
+ const REMOTE_URL = /https?:\/\/[^\s"'<>()\\,]+/gi;
18
+ const IMAGE_PATH = /\.(?:jpe?g|png|gif|webp|avif|svg|ico|bmp|tiff?)$/i;
19
+
20
+ /** @param {string} path @returns {"catalog"|"blog"|"pages"|"other"} */
21
+ export function imageTypeOf(path) {
22
+ if (path.startsWith("content/catalog/")) return "catalog";
23
+ if (path.startsWith("content/blog/")) return "blog";
24
+ if (PAGES_PREFIXES.some((prefix) => path.startsWith(prefix))) return "pages";
25
+ return "other";
26
+ }
27
+
28
+ /** Whether `candidate` names a remote image: an image file extension, or a Shopify files path. */
29
+ function isRemoteImage(candidate) {
30
+ let url;
31
+ try { url = new URL(candidate); } catch { return false; }
32
+ return IMAGE_PATH.test(url.pathname) || /(?:^|\.)cdn\.shopify\.com$/i.test(url.hostname) && url.pathname.includes("/files/");
33
+ }
34
+
35
+ /**
36
+ * Source-hosted image references under `content/`, `public/` and `theme.json`, by type. A file over
37
+ * the byte cap counts as one uncaptured reference, as the publish census counts it.
38
+ * @param {string} dir the tenant checkout
39
+ * @returns {Record<"catalog"|"blog"|"pages"|"other", {files: number, references: number, uncaptured: number}>}
40
+ */
41
+ export function countSourceImages(dir) {
42
+ const out = Object.fromEntries(IMAGE_TYPES.map((type) => [type, { files: 0, references: 0, uncaptured: 0 }]));
43
+ /** @param {string} rel */
44
+ const visit = (rel) => {
45
+ const absolute = join(dir, rel);
46
+ const stat = lstatSync(absolute, { throwIfNoEntry: false });
47
+ if (!stat || stat.isSymbolicLink()) return;
48
+ if (stat.isDirectory()) {
49
+ for (const name of readdirSync(absolute).sort()) visit(rel ? `${rel}/${name}` : name);
50
+ return;
51
+ }
52
+ if (!stat.isFile() || !TEXT.test(rel)) return;
53
+ const bucket = out[imageTypeOf(rel)];
54
+ bucket.files += 1;
55
+ if (stat.size > MAX_TEXT) { bucket.references += 1; bucket.uncaptured += 1; return; }
56
+ const found = (readFileSync(absolute, "utf8").match(REMOTE_URL) ?? []).filter(isRemoteImage).length;
57
+ bucket.references += found;
58
+ bucket.uncaptured += found;
59
+ };
60
+ visit("content");
61
+ visit("public");
62
+ visit("theme.json");
63
+ return /** @type {any} */ (out);
64
+ }
65
+
66
+ /** `catalog 0, blog 3, pages 1, other 0` */
67
+ export function formatImageTypes(types) {
68
+ return IMAGE_TYPES.map((type) => `${type} ${types[type].uncaptured}`).join(", ");
69
+ }
70
+
71
+ /**
72
+ * A per-type census from untrusted JSON (a server response): every type must carry non-negative
73
+ * integer counts, or the whole thing is null — nothing is invented for a missing type.
74
+ * @param {unknown} raw
75
+ */
76
+ export function normalizeImageTypes(raw) {
77
+ if (!raw || typeof raw !== "object") return null;
78
+ const out = {};
79
+ for (const type of IMAGE_TYPES) {
80
+ const c = /** @type {any} */ (raw)[type];
81
+ const counts = [c?.files, c?.references, c?.uncaptured];
82
+ if (!counts.every((n) => Number.isSafeInteger(n) && n >= 0)) return null;
83
+ out[type] = { files: c.files, references: c.references, uncaptured: c.uncaptured };
84
+ }
85
+ return out;
86
+ }