@tokenoftrust/cli 4.10.0 → 4.11.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "4.10.0",
3
+ "version": "4.11.1",
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",
@@ -10,8 +10,9 @@
10
10
  * input; `agency-kit/tools/lib/fixtures/analytics-declarations.json` is run through both, so a
11
11
  * drift on either side fails a test.
12
12
  *
13
- * Messages carry no value from the file — never an id, never a key's value — only the field path
14
- * and a fixed explanation, so a finding is safe to print, log or forward.
13
+ * Messages carry no value from the file — never an id, never a key's value, never an unrecognized
14
+ * key — only the field path and a fixed explanation, so a finding is safe to print, log or forward.
15
+ * An unrecognized key is anchored by its position among the rejected keys (`$property[0]`).
15
16
  *
16
17
  * Pure: callers pass the file's text. `packages/cli/src/analytics-declaration.mjs` is a
17
18
  * byte-identical copy (a test pins it).
@@ -77,6 +78,17 @@ function isPlainObject(value) {
77
78
  return typeof value === "object" && value !== null && !Array.isArray(value);
78
79
  }
79
80
 
81
+ /**
82
+ * The anchor for the `ordinal`-th rejected key of an object. A rejected key is untrusted text, so
83
+ * the path names its position among the rejected keys, never the key.
84
+ * @param {string} base
85
+ * @param {number} ordinal
86
+ */
87
+ function rejectedKeyPath(base, ordinal) {
88
+ const anchor = `$property[${ordinal}]`;
89
+ return base ? `${base}.${anchor}` : anchor;
90
+ }
91
+
80
92
  /**
81
93
  * @param {Record<string, unknown>} obj
82
94
  * @param {readonly string[]} allowed
@@ -84,9 +96,11 @@ function isPlainObject(value) {
84
96
  * @param {AnalyticsViolation[]} out
85
97
  */
86
98
  function unknownKeys(obj, allowed, base, out) {
99
+ let ordinal = 0;
87
100
  for (const key of Object.keys(obj)) {
88
101
  if (allowed.includes(key) || ANNOTATION_KEYS.includes(key)) continue;
89
- out.push({ path: base ? `${base}.${key}` : key, code: "unknown_field" });
102
+ out.push({ path: rejectedKeyPath(base, ordinal), code: "unknown_field" });
103
+ ordinal += 1;
90
104
  }
91
105
  }
92
106
 
@@ -100,12 +114,14 @@ function unknownKeys(obj, allowed, base, out) {
100
114
  function stageIdViolations(ids, adapter, base, out) {
101
115
  /** @type {Map<string, string[]>} */
102
116
  const valid = new Map();
117
+ let rejectedStages = 0;
103
118
  for (const [stage, raw] of Object.entries(ids)) {
104
- const at = `${base}.ids.${stage}`;
105
119
  if (!ANALYTICS_STAGES.includes(stage)) {
106
- out.push({ path: at, code: "unknown_enum" });
120
+ out.push({ path: rejectedKeyPath(`${base}.ids`, rejectedStages), code: "unknown_enum" });
121
+ rejectedStages += 1;
107
122
  continue;
108
123
  }
124
+ const at = `${base}.ids.${stage}`;
109
125
  if (!Array.isArray(raw)) {
110
126
  out.push({ path: at, code: "type" });
111
127
  continue;
@@ -37,6 +37,7 @@ 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";
40
41
 
41
42
  const USAGE = `tot go-live — cut the apex domain over to the storefront
42
43
 
@@ -111,6 +112,7 @@ export function normalizeReadiness(r) {
111
112
  ? rd.blockedReasons.filter((s) => typeof s === "string")
112
113
  : checks.filter((c) => !c.ok).map((c) => c.detail),
113
114
  appDomain: typeof o.appDomain === "string" ? o.appDomain : null,
115
+ imageTypes: normalizeImageTypes(o.imageTypes),
114
116
  domainState: rd && o.domain && typeof o.domain === "object" ? o.domain : null,
115
117
  };
116
118
  }
@@ -201,6 +203,10 @@ export function renderReadiness({ appDomain, readiness, action }) {
201
203
  if (!c.ok && c.remedy) lines.push(` → ${c.remedy}`);
202
204
  }
203
205
  if (action === "connect") {
206
+ lines.push(
207
+ "",
208
+ ` Uncaptured images by type: ${readiness.imageTypes ? formatImageTypes(readiness.imageTypes) : "not recorded for the live bundle"}`,
209
+ );
204
210
  lines.push(
205
211
  "",
206
212
  readiness.ready
@@ -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
+ }