@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.
|
|
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
|
|
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
|
|
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:
|
|
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;
|
package/src/commands/go-live.mjs
CHANGED
|
@@ -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
|
+
}
|