@tokenoftrust/cli 2.2.13 → 2.2.15
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/declared-config.mjs +78 -0
- package/src/tot-repo-mappings.mjs +153 -0
- package/src/validate.mjs +77 -94
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.15",
|
|
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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `.tot/config.json` keys the platform reads, and the refusal of everything
|
|
3
|
+
* else. Shared by `tot validate` (src/validate.mjs) and the monorepo/dev-loop
|
|
4
|
+
* validator (scripts/tenant/validate.mjs), so both refuse the same keys.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors packages/public-runtime/src/declared-tenant-config.mjs (this package ships
|
|
7
|
+
* to npm on its own); test/declared-config-parity.test.mjs pins the two together, so
|
|
8
|
+
* a field the platform honors can never be refused here, and a key no surface reads
|
|
9
|
+
* can never pass. Dependency-free: the storefront runner copies this file as-is.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const TOT_CONFIG_KEYS = [
|
|
13
|
+
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
14
|
+
"siteType", "compliance", "capabilities", "features",
|
|
15
|
+
];
|
|
16
|
+
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
17
|
+
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
18
|
+
export const COMPLIANCE_KEYS = [
|
|
19
|
+
"ruleProfile", "minAge", "nicotineWarning", "shippingRestriction", "pactAct",
|
|
20
|
+
"adultSignature", "prop65", "purchaseLimit", "stateEligibility", "exciseTax",
|
|
21
|
+
];
|
|
22
|
+
export const FEATURE_KEYS = [
|
|
23
|
+
"catalog", "productSearch", "savedItems", "quickView", "newsletter", "commerceSitemap", "productJsonLd", "ageGate",
|
|
24
|
+
];
|
|
25
|
+
export const SITE_TYPES = ["commerce", "marketing"];
|
|
26
|
+
|
|
27
|
+
const ERROR = "error";
|
|
28
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
29
|
+
|
|
30
|
+
/** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
|
|
31
|
+
function mk(level, rule, file, message, fix) {
|
|
32
|
+
return { level, rule, file, message, fix };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Refuse what the platform would silently ignore: an unknown top-level key, an
|
|
37
|
+
* unknown `compliance`/`features` key, or a declared field of the wrong shape. Each
|
|
38
|
+
* one passes JSON parsing and renders nothing — on a regulated store that is a
|
|
39
|
+
* mandated notice that never appears.
|
|
40
|
+
* @param {Record<string, any>} config parsed `.tot/config.json` (an object)
|
|
41
|
+
* @param {string} [file]
|
|
42
|
+
* @returns {Finding[]}
|
|
43
|
+
*/
|
|
44
|
+
export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const key of Object.keys(config)) {
|
|
47
|
+
if (!TOT_CONFIG_KEYS.includes(key)) {
|
|
48
|
+
const renamed = RENAMED_CONFIG_KEYS[/** @type {keyof typeof RENAMED_CONFIG_KEYS} */ (key)];
|
|
49
|
+
out.push(mk(ERROR, "config-unknown-key", file,
|
|
50
|
+
`\`${key}\` is not a .tot/config.json key — the platform never reads it`,
|
|
51
|
+
renamed ? `use \`${renamed}\`` : `known keys: ${TOT_CONFIG_KEYS.join(", ")}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
|
|
55
|
+
out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
|
|
56
|
+
}
|
|
57
|
+
/** @type {Array<[string, string[]]>} */
|
|
58
|
+
const objectFields = [["compliance", COMPLIANCE_KEYS], ["features", FEATURE_KEYS]];
|
|
59
|
+
for (const [field, known] of objectFields) {
|
|
60
|
+
const value = config[field];
|
|
61
|
+
if (value === undefined) continue;
|
|
62
|
+
if (!isObject(value)) {
|
|
63
|
+
out.push(mk(ERROR, `config-${field}`, file, `\`${field}\` must be an object`));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const key of Object.keys(value)) {
|
|
67
|
+
if (!known.includes(key)) {
|
|
68
|
+
out.push(mk(ERROR, `config-${field}-unknown-key`, `${file} ${field}`,
|
|
69
|
+
`\`${field}.${key}\` is not a ${field} key — nothing renders from it`,
|
|
70
|
+
`known keys: ${known.join(", ")}`));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (config.capabilities !== undefined && !isObject(config.capabilities)) {
|
|
75
|
+
out.push(mk(ERROR, "config-capabilities", file, "`capabilities` must be an object"));
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.tot/config.json` mapping resolution — the ONE reading of a store repo's
|
|
3
|
+
* declared layout, shared by every consumer: the hosted reconcile (preview,
|
|
4
|
+
* candidate, aggregate, publication), `tot dev`'s checkout graft, `tot validate`,
|
|
5
|
+
* the platform's materialize / asset-repair scripts, and this kit's
|
|
6
|
+
* `repair-tenant-config.mjs`. The platform imports this file from the kit.
|
|
7
|
+
*
|
|
8
|
+
* A mapping is `{ workspace, repo, kind }`:
|
|
9
|
+
* - `workspace` — where the files physically live in the store repo. It is the
|
|
10
|
+
* only source of truth for location; nothing assumes a `tenants/<id>/` layout
|
|
11
|
+
* inside the store's own repo.
|
|
12
|
+
* - `repo` — the storefront artifact target, `tenants/<segment>/<rel>`. Only
|
|
13
|
+
* `<rel>` (the tenant-relative path: `theme.json`, `content/`, `public/`, …)
|
|
14
|
+
* is read from it. The tenant namespace always comes from the CANONICAL
|
|
15
|
+
* tenant id — the store's registered id hosted, the config's `tenant` locally
|
|
16
|
+
* — never from `<segment>`, so a config whose segment disagrees with its
|
|
17
|
+
* tenant id resolves identically on every surface.
|
|
18
|
+
*
|
|
19
|
+
* Pure and dependency-free so kit tools, platform `.mjs` scripts and TypeScript
|
|
20
|
+
* consumers (types in the adjacent `.d.mts`) all import it. The published CLI
|
|
21
|
+
* installs without the kit, so it ships a byte-identical copy that its own test
|
|
22
|
+
* suite holds equal to this file.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Platform test tenants nest one level deeper: `tenants/e2e/<id>.e2e.test/`. */
|
|
26
|
+
const TARGET_RE = /^tenants\/(?:e2e\/([^/]+\.e2e\.test)|([^/]+))(?:\/(.*))?$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Split a declared mapping target into the tenant segment it names and the
|
|
30
|
+
* tenant-relative path it addresses. Null when the target is not under
|
|
31
|
+
* `tenants/<segment>/`, or when its relative part is not a clean path (empty,
|
|
32
|
+
* `.` or `..` segments) — such a target could address outside the tenant.
|
|
33
|
+
* A trailing `/` (tree target) is preserved on `rel`; the tenant root is `""`.
|
|
34
|
+
* @param {unknown} repo
|
|
35
|
+
* @returns {{ segment: string, rel: string } | null}
|
|
36
|
+
*/
|
|
37
|
+
export function parseMappingTarget(repo) {
|
|
38
|
+
if (typeof repo !== "string") return null;
|
|
39
|
+
const m = TARGET_RE.exec(repo);
|
|
40
|
+
if (!m) return null;
|
|
41
|
+
const segment = m[1] ?? m[2];
|
|
42
|
+
const rel = m[3] ?? "";
|
|
43
|
+
const parts = rel.replace(/\/$/, "").split("/");
|
|
44
|
+
if (rel && parts.some((p) => !p || p === "." || p === "..")) return null;
|
|
45
|
+
return { segment, rel };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The canonical artifact root for a tenant: `tenants/<tenantId>/`. */
|
|
49
|
+
export function tenantArtifactRoot(tenantId) {
|
|
50
|
+
return `tenants/${tenantId}/`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function asDir(path) {
|
|
54
|
+
return path === "" || path.endsWith("/") ? path : `${path}/`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mappingsOf(config) {
|
|
58
|
+
return Array.isArray(config?.mappings) ? config.mappings : [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Map a tenant-repo path to its tenant-relative path (e.g. `content/home.json`).
|
|
63
|
+
* First match wins; a `file` mapping matches exactly, a `tree` mapping by prefix.
|
|
64
|
+
* Null when no mapping covers the path or the covering mapping's target is not
|
|
65
|
+
* a valid tenant target.
|
|
66
|
+
* @param {{ mappings?: unknown }} config
|
|
67
|
+
* @param {string} workspacePath
|
|
68
|
+
* @returns {string | null}
|
|
69
|
+
*/
|
|
70
|
+
export function mapWorkspaceToTenantRelative(config, workspacePath) {
|
|
71
|
+
for (const m of mappingsOf(config)) {
|
|
72
|
+
if (!m || typeof m !== "object" || typeof m.workspace !== "string") continue;
|
|
73
|
+
if (m.kind === "file") {
|
|
74
|
+
if (workspacePath !== m.workspace) continue;
|
|
75
|
+
const target = parseMappingTarget(m.repo);
|
|
76
|
+
return target ? target.rel : null;
|
|
77
|
+
}
|
|
78
|
+
if (m.kind === "tree") {
|
|
79
|
+
const ws = asDir(m.workspace);
|
|
80
|
+
if (!workspacePath.startsWith(ws)) continue;
|
|
81
|
+
const target = parseMappingTarget(m.repo);
|
|
82
|
+
return target ? asDir(target.rel) + workspacePath.slice(ws.length) : null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Map a tenant-repo path to its storefront artifact path under the CANONICAL
|
|
90
|
+
* tenant root (`tenants/<tenantId>/<rel>`). Null when unmapped.
|
|
91
|
+
* @param {{ mappings?: unknown }} config
|
|
92
|
+
* @param {string} tenantId
|
|
93
|
+
* @param {string} workspacePath
|
|
94
|
+
* @returns {string | null}
|
|
95
|
+
*/
|
|
96
|
+
export function mapWorkspaceToArtifact(config, tenantId, workspacePath) {
|
|
97
|
+
const rel = mapWorkspaceToTenantRelative(config, workspacePath);
|
|
98
|
+
return rel === null ? null : tenantArtifactRoot(tenantId) + rel;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The inverse: where a tenant-relative path (`theme.json`, `content`, `public`,
|
|
103
|
+
* `content/home.json`) lives in the tenant repo, per the declared mappings.
|
|
104
|
+
* Unmapped paths resolve to themselves — the flat checkout layout — so a
|
|
105
|
+
* consumer still finds files the mappings do not cover (and the config-coverage
|
|
106
|
+
* check reports the gap).
|
|
107
|
+
* @param {{ mappings?: unknown }} config
|
|
108
|
+
* @param {string} relPath tenant-relative, no leading `/`
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function workspacePathForTenantRelative(config, relPath) {
|
|
112
|
+
const bare = relPath.replace(/\/$/, "");
|
|
113
|
+
for (const m of mappingsOf(config)) {
|
|
114
|
+
if (!m || typeof m !== "object" || typeof m.workspace !== "string") continue;
|
|
115
|
+
const target = parseMappingTarget(m.repo);
|
|
116
|
+
if (!target) continue;
|
|
117
|
+
const targetRel = target.rel.replace(/\/$/, "");
|
|
118
|
+
const ws = m.workspace.replace(/\/$/, "");
|
|
119
|
+
if (m.kind === "file") {
|
|
120
|
+
if (targetRel === bare) return ws;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (m.kind !== "tree") continue;
|
|
124
|
+
if (targetRel === bare) return ws;
|
|
125
|
+
const prefix = asDir(targetRel);
|
|
126
|
+
if (bare.startsWith(prefix)) {
|
|
127
|
+
const rest = bare.slice(prefix.length);
|
|
128
|
+
return ws ? `${ws}/${rest}` : rest;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return bare;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Mappings whose declared target names a tenant segment other than the
|
|
136
|
+
* canonical tenant id. They still resolve correctly (the segment is never
|
|
137
|
+
* read); this exists so validators can tell the author the config disagrees
|
|
138
|
+
* with itself.
|
|
139
|
+
* @param {{ mappings?: unknown }} config
|
|
140
|
+
* @param {string} tenantId
|
|
141
|
+
* @returns {{ workspace: string, repo: string, segment: string }[]}
|
|
142
|
+
*/
|
|
143
|
+
export function foreignTenantSegments(config, tenantId) {
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const m of mappingsOf(config)) {
|
|
146
|
+
if (!m || typeof m !== "object") continue;
|
|
147
|
+
const target = parseMappingTarget(m.repo);
|
|
148
|
+
if (target && target.segment !== tenantId) {
|
|
149
|
+
out.push({ workspace: String(m.workspace ?? ""), repo: m.repo, segment: target.segment });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
package/src/validate.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Design principle — "valid locally ≡ will reconcile in storefront": these checks
|
|
7
7
|
* mirror the reconcile pipeline's own gate (@tot/private-controlplane
|
|
8
|
-
* customization-reconcile.ts `
|
|
8
|
+
* customization-reconcile.ts `validateMappingShape` / `validateRawHtml`, and the shared mapping resolver `tot-repo-mappings.mjs`). Kept in step
|
|
9
9
|
* with the in-repo source of truth `scripts/tenant/validate.mjs` (which the CI
|
|
10
10
|
* regression test imports) — including its platform-route awareness (commerce
|
|
11
11
|
* tenants own /collections, /products/<handle>, /search, /saved, /account, /cart,
|
|
@@ -17,6 +17,20 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
19
19
|
import { join, relative } from "node:path";
|
|
20
|
+
import { validateDeclaredConfig } from "./declared-config.mjs";
|
|
21
|
+
import {
|
|
22
|
+
foreignTenantSegments,
|
|
23
|
+
parseMappingTarget,
|
|
24
|
+
workspacePathForTenantRelative,
|
|
25
|
+
} from "./tot-repo-mappings.mjs";
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
TOT_CONFIG_KEYS,
|
|
29
|
+
COMPLIANCE_KEYS,
|
|
30
|
+
FEATURE_KEYS,
|
|
31
|
+
SITE_TYPES,
|
|
32
|
+
validateDeclaredConfig,
|
|
33
|
+
} from "./declared-config.mjs";
|
|
20
34
|
|
|
21
35
|
const NUL = String.fromCharCode(0);
|
|
22
36
|
export const ERROR = "error";
|
|
@@ -63,74 +77,10 @@ const hasScanExt = (p) => CONFLICT_SCAN_EXT.has((p.match(/\.[^./\\]+$/) || [""])
|
|
|
63
77
|
const KNOWN_KINDS = new Set(["file", "tree"]);
|
|
64
78
|
const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
|
|
65
79
|
|
|
66
|
-
// The keys the platform reads in `.tot/config.json`. Mirrors
|
|
67
|
-
// packages/public-runtime/src/declared-tenant-config.mjs (this package ships to npm
|
|
68
|
-
// on its own); test/declared-config-parity.test.mjs pins the two together, so a
|
|
69
|
-
// field the platform honors can never be refused here, and a key no surface reads
|
|
70
|
-
// can never pass.
|
|
71
|
-
export const TOT_CONFIG_KEYS = [
|
|
72
|
-
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
73
|
-
"siteType", "compliance", "capabilities", "features",
|
|
74
|
-
];
|
|
75
|
-
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
76
|
-
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
77
|
-
export const COMPLIANCE_KEYS = [
|
|
78
|
-
"ruleProfile", "minAge", "nicotineWarning", "shippingRestriction", "pactAct",
|
|
79
|
-
"adultSignature", "prop65", "purchaseLimit", "stateEligibility", "exciseTax",
|
|
80
|
-
];
|
|
81
|
-
export const FEATURE_KEYS = [
|
|
82
|
-
"catalog", "productSearch", "savedItems", "quickView", "newsletter", "commerceSitemap", "productJsonLd", "ageGate",
|
|
83
|
-
];
|
|
84
|
-
export const SITE_TYPES = ["commerce", "marketing"];
|
|
85
|
-
|
|
86
|
-
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Refuse what the platform would silently ignore: an unknown top-level key, an
|
|
90
|
-
* unknown `compliance`/`features` key, or a declared field of the wrong shape. Each
|
|
91
|
-
* one passes JSON parsing and renders nothing — on a regulated store that is a
|
|
92
|
-
* mandated notice that never appears.
|
|
93
|
-
* @returns {Finding[]}
|
|
94
|
-
*/
|
|
95
|
-
export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
96
|
-
const out = [];
|
|
97
|
-
for (const key of Object.keys(config)) {
|
|
98
|
-
if (!TOT_CONFIG_KEYS.includes(key)) {
|
|
99
|
-
const renamed = RENAMED_CONFIG_KEYS[key];
|
|
100
|
-
out.push(mk(ERROR, "config-unknown-key", file,
|
|
101
|
-
`\`${key}\` is not a .tot/config.json key — the platform never reads it`,
|
|
102
|
-
renamed ? `use \`${renamed}\`` : `known keys: ${TOT_CONFIG_KEYS.join(", ")}`));
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
|
|
106
|
-
out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
|
|
107
|
-
}
|
|
108
|
-
/** @type {Array<[string, string[]]>} */
|
|
109
|
-
const objectFields = [["compliance", COMPLIANCE_KEYS], ["features", FEATURE_KEYS]];
|
|
110
|
-
for (const [field, known] of objectFields) {
|
|
111
|
-
const value = config[field];
|
|
112
|
-
if (value === undefined) continue;
|
|
113
|
-
if (!isObject(value)) {
|
|
114
|
-
out.push(mk(ERROR, `config-${field}`, file, `\`${field}\` must be an object`));
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
for (const key of Object.keys(value)) {
|
|
118
|
-
if (!known.includes(key)) {
|
|
119
|
-
out.push(mk(ERROR, `config-${field}-unknown-key`, `${file} ${field}`,
|
|
120
|
-
`\`${field}.${key}\` is not a ${field} key — nothing renders from it`,
|
|
121
|
-
`known keys: ${known.join(", ")}`));
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
if (config.capabilities !== undefined && !isObject(config.capabilities)) {
|
|
126
|
-
out.push(mk(ERROR, "config-capabilities", file, "`capabilities` must be an object"));
|
|
127
|
-
}
|
|
128
|
-
return out;
|
|
129
|
-
}
|
|
130
80
|
|
|
131
81
|
/**
|
|
132
82
|
* Validate a parsed `.tot/config.json` against the shape the reconcile pipeline
|
|
133
|
-
* (`
|
|
83
|
+
* (`validateMappingShape`) requires. Pure — no I/O.
|
|
134
84
|
* @returns {Finding[]}
|
|
135
85
|
*/
|
|
136
86
|
export function validateConfigShape(config, file = ".tot/config.json") {
|
|
@@ -155,18 +105,29 @@ export function validateConfigShape(config, file = ".tot/config.json") {
|
|
|
155
105
|
}
|
|
156
106
|
if (typeof m.workspace !== "string" || !m.workspace) {
|
|
157
107
|
out.push(mk(ERROR, "mapping-workspace", at, "`workspace` must be a non-empty string"));
|
|
158
|
-
} else {
|
|
159
|
-
seen.add(m.workspace.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
|
|
160
|
-
seen.add(m.workspace);
|
|
161
108
|
}
|
|
162
|
-
// THE regression guard: reconcile
|
|
163
|
-
// knows "file" vs tree — a missing repo or
|
|
109
|
+
// THE regression guard: reconcile reads the tenant-relative target out of
|
|
110
|
+
// m.repo and only knows "file" vs tree — a missing/unparseable repo or
|
|
111
|
+
// kind:"dir" is refused there, so refuse it here first.
|
|
164
112
|
if (typeof m.repo !== "string" || !m.repo) {
|
|
165
113
|
out.push(
|
|
166
114
|
mk(ERROR, "mapping-repo", at,
|
|
167
|
-
`mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (reconcile
|
|
115
|
+
`mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (the reconcile refuses it)`,
|
|
168
116
|
`add "repo": "tenants/${config.tenant ?? "<id>"}/${(m.workspace ?? "").replace(/\/+$/, "")}${m.kind === "file" ? "" : "/"}"`),
|
|
169
117
|
);
|
|
118
|
+
} else {
|
|
119
|
+
const target = parseMappingTarget(m.repo);
|
|
120
|
+
if (!target) {
|
|
121
|
+
out.push(
|
|
122
|
+
mk(ERROR, "mapping-repo", at,
|
|
123
|
+
`mapping for "${m.workspace ?? "?"}" targets "${m.repo}" — \`repo\` must be a clean path under tenants/<id>/ (the reconcile refuses it)`,
|
|
124
|
+
`e.g. "repo": "tenants/${config.tenant ?? "<id>"}/content/"`),
|
|
125
|
+
);
|
|
126
|
+
} else {
|
|
127
|
+
// Coverage is about WHAT is mapped (the target), not where it lives.
|
|
128
|
+
seen.add(target.rel.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
|
|
129
|
+
seen.add(target.rel);
|
|
130
|
+
}
|
|
170
131
|
}
|
|
171
132
|
if (!KNOWN_KINDS.has(m.kind)) {
|
|
172
133
|
out.push(
|
|
@@ -1091,16 +1052,9 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1091
1052
|
/** @type {Finding[]} */
|
|
1092
1053
|
const findings = [];
|
|
1093
1054
|
const rel = (p) => relative(tenantDir, p) || p;
|
|
1094
|
-
const contentDir = join(tenantDir, "content");
|
|
1095
|
-
const publicDir = join(tenantDir, "public");
|
|
1096
|
-
const pagesDir = join(contentDir, "pages-html");
|
|
1097
|
-
|
|
1098
|
-
// 0. basic layout
|
|
1099
|
-
if (!existsSync(contentDir)) {
|
|
1100
|
-
findings.push(mk(ERROR, "layout", "content/", "no content/ directory"));
|
|
1101
|
-
}
|
|
1102
1055
|
|
|
1103
|
-
// 1. .tot/config.json — shape (flow-back readiness)
|
|
1056
|
+
// 1. .tot/config.json — shape (flow-back readiness). Read first: its mappings
|
|
1057
|
+
// say where theme.json / content/ / public/ live in this tree.
|
|
1104
1058
|
const configPath = join(tenantDir, ".tot", "config.json");
|
|
1105
1059
|
let config = null;
|
|
1106
1060
|
if (existsSync(configPath)) {
|
|
@@ -1111,14 +1065,46 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1111
1065
|
config = value;
|
|
1112
1066
|
findings.push(...validateConfigShape(value, ".tot/config.json"));
|
|
1113
1067
|
}
|
|
1114
|
-
}
|
|
1068
|
+
}
|
|
1069
|
+
const located = (tenantRel) => join(tenantDir, workspacePathForTenantRelative(config, tenantRel));
|
|
1070
|
+
const contentDir = located("content");
|
|
1071
|
+
const publicDir = located("public");
|
|
1072
|
+
const pagesDir = join(contentDir, "pages-html");
|
|
1073
|
+
|
|
1074
|
+
// 0. basic layout
|
|
1075
|
+
if (!existsSync(contentDir)) {
|
|
1076
|
+
findings.push(mk(ERROR, "layout", rel(contentDir) + "/", `no ${rel(contentDir)}/ directory`));
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (!existsSync(configPath)) {
|
|
1115
1080
|
findings.push(
|
|
1116
1081
|
mk(WARN, "config-absent", ".tot/config.json",
|
|
1117
1082
|
"no .tot/config.json — tenant can serve but is NOT flow-back-ready (reconcile has nothing to map)"),
|
|
1118
1083
|
);
|
|
1119
1084
|
}
|
|
1120
1085
|
const scope = opts.scope || config?.scope;
|
|
1121
|
-
|
|
1086
|
+
// The canonical tenant id: the one the asset rule binds to and the one the
|
|
1087
|
+
// mappings' targets resolve under — never a directory name.
|
|
1088
|
+
const activeTenantId = opts.tenantId || config?.tenant || scope;
|
|
1089
|
+
if (config && activeTenantId) {
|
|
1090
|
+
for (const f of foreignTenantSegments(config, activeTenantId)) {
|
|
1091
|
+
findings.push(
|
|
1092
|
+
mk(WARN, "mapping-tenant-segment", ".tot/config.json",
|
|
1093
|
+
`mapping for "${f.workspace}" targets "${f.repo}", naming tenant "${f.segment}" — this store is "${activeTenantId}". ` +
|
|
1094
|
+
`It still resolves under tenants/${activeTenantId}/ (the segment is not read), and its assets are served at /tenants/${activeTenantId}/...`,
|
|
1095
|
+
`write "repo": "tenants/${activeTenantId}/${parseMappingTarget(f.repo)?.rel ?? ""}" so the config agrees with itself ` +
|
|
1096
|
+
`(the agency kit's tools/repair-tenant-config.mjs rewrites the whole config)`),
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
// Publication refuses a config whose `scope` is not the store's id.
|
|
1101
|
+
if (config && typeof config.tenant === "string" && config.scope !== config.tenant) {
|
|
1102
|
+
findings.push(
|
|
1103
|
+
mk(WARN, "config-scope-mismatch", ".tot/config.json",
|
|
1104
|
+
`"scope" is ${JSON.stringify(config.scope ?? null)} but "tenant" is "${config.tenant}" — publication refuses a config whose scope is not the store's id`,
|
|
1105
|
+
`set "scope" to "${config.tenant}" (the agency kit's tools/repair-tenant-config.mjs rewrites the whole config)`),
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1122
1108
|
// platform-route awareness: which framework routes the host platform owns for this tenant
|
|
1123
1109
|
const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
|
|
1124
1110
|
const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
|
|
@@ -1128,18 +1114,18 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1128
1114
|
}
|
|
1129
1115
|
|
|
1130
1116
|
// 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
|
|
1131
|
-
const themePath =
|
|
1117
|
+
const themePath = located("theme.json");
|
|
1132
1118
|
if (existsSync(themePath)) {
|
|
1133
1119
|
const { error } = readJsonSafe(themePath);
|
|
1134
1120
|
if (error) {
|
|
1135
1121
|
findings.push(
|
|
1136
|
-
mk(ERROR, "theme-parse",
|
|
1122
|
+
mk(ERROR, "theme-parse", rel(themePath),
|
|
1137
1123
|
`invalid JSON: ${error}`, "theme.json is loaded by an eager Vite glob — a parse error fails the entire storefront build"),
|
|
1138
1124
|
);
|
|
1139
1125
|
}
|
|
1140
1126
|
}
|
|
1141
1127
|
|
|
1142
|
-
const capabilitiesPath =
|
|
1128
|
+
const capabilitiesPath = located("capabilities.json");
|
|
1143
1129
|
if (existsSync(capabilitiesPath)) {
|
|
1144
1130
|
const { value, error } = readJsonSafe(capabilitiesPath);
|
|
1145
1131
|
if (error) {
|
|
@@ -1149,7 +1135,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1149
1135
|
}
|
|
1150
1136
|
}
|
|
1151
1137
|
|
|
1152
|
-
const scriptsPath =
|
|
1138
|
+
const scriptsPath = located("scripts.json");
|
|
1153
1139
|
if (existsSync(scriptsPath)) {
|
|
1154
1140
|
const { value, error } = readJsonSafe(scriptsPath);
|
|
1155
1141
|
if (error) {
|
|
@@ -1159,7 +1145,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1159
1145
|
}
|
|
1160
1146
|
}
|
|
1161
1147
|
|
|
1162
|
-
const embedsPath =
|
|
1148
|
+
const embedsPath = located("embeds.json");
|
|
1163
1149
|
if (existsSync(embedsPath)) {
|
|
1164
1150
|
const { value, error } = readJsonSafe(embedsPath);
|
|
1165
1151
|
if (error) {
|
|
@@ -1259,9 +1245,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1259
1245
|
// content/ plus the root config files.
|
|
1260
1246
|
const conflictScanFiles = [
|
|
1261
1247
|
...walk(contentDir, hasScanExt),
|
|
1262
|
-
...[
|
|
1263
|
-
.map((f) => join(tenantDir, f))
|
|
1264
|
-
.filter((p) => existsSync(p)),
|
|
1248
|
+
...[themePath, capabilitiesPath, scriptsPath, configPath].filter((p) => existsSync(p)),
|
|
1265
1249
|
];
|
|
1266
1250
|
for (const p of conflictScanFiles) {
|
|
1267
1251
|
const lines = detectConflictMarkers(readFileSync(p, "utf8"));
|
|
@@ -1280,9 +1264,8 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1280
1264
|
// origin, is blocking; advisory (info) when there's no embeds.json to compare.
|
|
1281
1265
|
{
|
|
1282
1266
|
let declaredEmbeds = null;
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
const { value } = readJsonSafe(embedsFile);
|
|
1267
|
+
if (existsSync(embedsPath)) {
|
|
1268
|
+
const { value } = readJsonSafe(embedsPath);
|
|
1286
1269
|
if (Array.isArray(value)) declaredEmbeds = new Set(value.filter((s) => typeof s === "string"));
|
|
1287
1270
|
}
|
|
1288
1271
|
for (const file of walk(contentDir, (p) => p.endsWith(".html"))) {
|