@tokenoftrust/cli 3.0.4 → 3.0.6
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/public-binaries.mjs +111 -0
- package/src/validate.mjs +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.6",
|
|
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",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant binaries never live in the tenant repo. The platform's content-addressed `/_a/<sha256>`
|
|
3
|
+
* pool owns them and the store's repo holds references only. Platform capture of catalog images
|
|
4
|
+
* is pending: until it owns them, an image stays at its source URL, go-live requires 0 uncaptured
|
|
5
|
+
* catalog images, and the source store is never shut down before that check passes. This decides,
|
|
6
|
+
* from a listing of the store's `public/` tree, whether the repo is carrying binaries it must not.
|
|
7
|
+
*
|
|
8
|
+
* Findings, each naming its remedy:
|
|
9
|
+
*
|
|
10
|
+
* - one per entry of {@link FORBIDDEN_PUBLIC_DIRS}: any file under a directory a media download
|
|
11
|
+
* writes (`public/catalog-images/` is `catalog-images-in-repo`, `public/blog/wp-content/uploads/`
|
|
12
|
+
* is `blog-media-in-repo`). Decided from paths alone, so a caller without sizes still refuses
|
|
13
|
+
* it. This list is the one source the validators and the reconcile read; the Gitea
|
|
14
|
+
* pre-receive size guard names the same directories.
|
|
15
|
+
* - `public-binary-oversized` — one raster or media file over {@link PUBLIC_BINARY_LIMITS}
|
|
16
|
+
* `maxFileBytes`.
|
|
17
|
+
* - `public-tree-over-budget` — the whole `public/` tree over `maxTotalBytes`.
|
|
18
|
+
*
|
|
19
|
+
* The size findings need `bytes`; an entry without it is judged by path only.
|
|
20
|
+
*
|
|
21
|
+
* Budget basis (measured 2026-10-20 on three live store repos' `preview` branches): public/ held
|
|
22
|
+
* 17.2 MB (384 files, largest 0.9 MB), 16.5 MB (largest 0.4 MB) and 0.2 MB outside
|
|
23
|
+
* catalog-images/. No legitimate file exceeded 1 MB.
|
|
24
|
+
*
|
|
25
|
+
* Pure: no filesystem, no network, no imports, so it runs in Node and in the Worker.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @type {{ maxFileBytes: number, maxTotalBytes: number }} */
|
|
29
|
+
export const PUBLIC_BINARY_LIMITS = Object.freeze({
|
|
30
|
+
maxFileBytes: 1024 * 1024,
|
|
31
|
+
maxTotalBytes: 32 * 1024 * 1024,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The directories under `public/` a media download writes. No file under one may be committed.
|
|
36
|
+
*
|
|
37
|
+
* @type {ReadonlyArray<Readonly<{ dir: string, code: string, noun: string }>>}
|
|
38
|
+
*/
|
|
39
|
+
export const FORBIDDEN_PUBLIC_DIRS = Object.freeze([
|
|
40
|
+
Object.freeze({ dir: "catalog-images/", code: "catalog-images-in-repo", noun: "catalog image" }),
|
|
41
|
+
Object.freeze({ dir: "blog/wp-content/uploads/", code: "blog-media-in-repo", noun: "blog media" }),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** The finding codes decided from paths alone. */
|
|
45
|
+
export const PATH_DECIDED_FINDING_CODES = Object.freeze(new Set(FORBIDDEN_PUBLIC_DIRS.map((d) => d.code)));
|
|
46
|
+
|
|
47
|
+
/** True for a path (relative to `public/`, `/` separators) under a forbidden directory. */
|
|
48
|
+
export function isForbiddenPublicPath(rel) {
|
|
49
|
+
return FORBIDDEN_PUBLIC_DIRS.some((d) => rel.startsWith(d.dir));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The kit command that moves downloaded catalog images and blog media back to their source URLs. */
|
|
53
|
+
export const UNHOST_IMAGES_COMMAND = "node agency-kit/tools/import-catalog.mjs --store <checkout> --unhost-images --write";
|
|
54
|
+
|
|
55
|
+
const RASTER_OR_MEDIA = /\.(?:jpe?g|png|gif|webp|avif|bmp|tiff?|heic|heif|ico|jxl|mp4|m4v|mov|webm|mkv|avi|mpe?g|mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
|
|
56
|
+
|
|
57
|
+
const mb = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {ReadonlyArray<{ rel: string, bytes?: number }>} entries files under `public/`, `rel`
|
|
61
|
+
* relative to it (`catalog-images/ab.jpg`)
|
|
62
|
+
* @param {{ maxFileBytes?: number, maxTotalBytes?: number }} [limits]
|
|
63
|
+
* @returns {Array<{ code: string, path: string, message: string, remedy: string }>}
|
|
64
|
+
*/
|
|
65
|
+
export function publicBinaryFindings(entries, limits = {}) {
|
|
66
|
+
const maxFileBytes = limits.maxFileBytes ?? PUBLIC_BINARY_LIMITS.maxFileBytes;
|
|
67
|
+
const maxTotalBytes = limits.maxTotalBytes ?? PUBLIC_BINARY_LIMITS.maxTotalBytes;
|
|
68
|
+
const findings = [];
|
|
69
|
+
for (const { dir, code, noun } of FORBIDDEN_PUBLIC_DIRS) {
|
|
70
|
+
const files = entries.filter((e) => e.rel.startsWith(dir));
|
|
71
|
+
if (files.length === 0) continue;
|
|
72
|
+
const bytes = files.reduce((n, e) => n + (e.bytes ?? 0), 0);
|
|
73
|
+
findings.push({
|
|
74
|
+
code,
|
|
75
|
+
path: `public/${dir}`,
|
|
76
|
+
message: `${files.length} ${noun} file(s)${bytes ? ` (${mb(bytes)})` : ""} are committed to the store repo; ` +
|
|
77
|
+
"images belong in the platform pool, not the repo; until platform capture owns them, keep each image at its source URL",
|
|
78
|
+
remedy: UNHOST_IMAGES_COMMAND,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
for (const e of entries) {
|
|
82
|
+
if (isForbiddenPublicPath(e.rel) || e.bytes === undefined) continue;
|
|
83
|
+
if (e.bytes > maxFileBytes && RASTER_OR_MEDIA.test(e.rel)) {
|
|
84
|
+
findings.push({
|
|
85
|
+
code: "public-binary-oversized",
|
|
86
|
+
path: `public/${e.rel}`,
|
|
87
|
+
message: `${mb(e.bytes)} raster/media file exceeds the ${mb(maxFileBytes)} per-file limit for the store repo; ` +
|
|
88
|
+
"images and media belong in the platform pool",
|
|
89
|
+
remedy: "reference the file by its source URL, or compress it under the limit",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (entries.every((e) => e.bytes !== undefined)) {
|
|
94
|
+
const total = entries.reduce((n, e) => n + /** @type {number} */ (e.bytes), 0);
|
|
95
|
+
if (total > maxTotalBytes) {
|
|
96
|
+
findings.push({
|
|
97
|
+
code: "public-tree-over-budget",
|
|
98
|
+
path: "public/",
|
|
99
|
+
message: `public/ holds ${mb(total)} across ${entries.length} file(s), over the ${mb(maxTotalBytes)} store-repo budget; ` +
|
|
100
|
+
"images and media belong in the platform pool",
|
|
101
|
+
remedy: "reference large or bulk assets by their source URL instead of committing them",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return findings;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One line per finding, remedy included: `<path>: [<code>] <message> — fix: <remedy>`. */
|
|
109
|
+
export function describePublicBinaryFindings(findings) {
|
|
110
|
+
return findings.map((f) => `${f.path}: [${f.code}] ${f.message} — fix: ${f.remedy}`);
|
|
111
|
+
}
|
package/src/validate.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
parseMappingTarget,
|
|
24
24
|
workspacePathForTenantRelative,
|
|
25
25
|
} from "./tot-repo-mappings.mjs";
|
|
26
|
+
import { isForbiddenPublicPath, publicBinaryFindings } from "./public-binaries.mjs";
|
|
26
27
|
|
|
27
28
|
export {
|
|
28
29
|
TOT_CONFIG_KEYS,
|
|
@@ -1350,6 +1351,13 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1350
1351
|
// large public assets — advisory (never blocks); see LARGE_PUBLIC_ASSET_FIX.
|
|
1351
1352
|
findings.push(...findLargePublicAssets(publicDir, rel));
|
|
1352
1353
|
|
|
1354
|
+
// Tenant binaries never live in the tenant repo — the same rule the hosted reconcile refuses.
|
|
1355
|
+
const publicEntries = walk(publicDir, () => true)
|
|
1356
|
+
.map((p) => ({ rel: relative(publicDir, p).split("\\").join("/"), bytes: statSync(p).size }));
|
|
1357
|
+
for (const f of publicBinaryFindings(publicEntries)) {
|
|
1358
|
+
findings.push(mk(ERROR, f.code, `${rel(publicDir)}/${f.path.slice("public/".length)}`, f.message, f.remedy));
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1353
1361
|
const ok = !findings.some((f) => f.level === ERROR);
|
|
1354
1362
|
return { ok, findings };
|
|
1355
1363
|
}
|
|
@@ -1426,6 +1434,8 @@ function checkAsset(src, file, publicDir, tenantId) {
|
|
|
1426
1434
|
function findLargePublicAssets(publicDir, rel) {
|
|
1427
1435
|
const out = [];
|
|
1428
1436
|
for (const p of walk(publicDir, () => true)) {
|
|
1437
|
+
// a forbidden media directory is already an error (catalog-images-in-repo, blog-media-in-repo); one line, not thousands.
|
|
1438
|
+
if (isForbiddenPublicPath(relative(publicDir, p).split("\\").join("/"))) continue;
|
|
1429
1439
|
const size = statSync(p).size;
|
|
1430
1440
|
if (size >= LARGE_PUBLIC_ASSET_BYTES) {
|
|
1431
1441
|
out.push(mk(WARN, "large-public-asset", rel(p), `${(size / 1024).toFixed(0)}KB`));
|