@tokenoftrust/cli 2.2.13 → 2.2.14

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": "2.2.13",
3
+ "version": "2.2.14",
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,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 `mapToArtifact` / `validateRawHtml`). Kept in step
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,11 @@
17
17
  */
18
18
  import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
19
19
  import { join, relative } from "node:path";
20
+ import {
21
+ foreignTenantSegments,
22
+ parseMappingTarget,
23
+ workspacePathForTenantRelative,
24
+ } from "./tot-repo-mappings.mjs";
20
25
 
21
26
  const NUL = String.fromCharCode(0);
22
27
  export const ERROR = "error";
@@ -130,7 +135,7 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
130
135
 
131
136
  /**
132
137
  * Validate a parsed `.tot/config.json` against the shape the reconcile pipeline
133
- * (`mapToArtifact`) requires. Pure — no I/O.
138
+ * (`validateMappingShape`) requires. Pure — no I/O.
134
139
  * @returns {Finding[]}
135
140
  */
136
141
  export function validateConfigShape(config, file = ".tot/config.json") {
@@ -155,18 +160,29 @@ export function validateConfigShape(config, file = ".tot/config.json") {
155
160
  }
156
161
  if (typeof m.workspace !== "string" || !m.workspace) {
157
162
  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
163
  }
162
- // THE regression guard: reconcile's mapToArtifact dereferences m.repo and only
163
- // knows "file" vs tree — a missing repo or kind:"dir" makes it throw at runtime.
164
+ // THE regression guard: reconcile reads the tenant-relative target out of
165
+ // m.repo and only knows "file" vs tree — a missing/unparseable repo or
166
+ // kind:"dir" is refused there, so refuse it here first.
164
167
  if (typeof m.repo !== "string" || !m.repo) {
165
168
  out.push(
166
169
  mk(ERROR, "mapping-repo", at,
167
- `mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (reconcile mapToArtifact throws on undefined.repo)`,
170
+ `mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (the reconcile refuses it)`,
168
171
  `add "repo": "tenants/${config.tenant ?? "<id>"}/${(m.workspace ?? "").replace(/\/+$/, "")}${m.kind === "file" ? "" : "/"}"`),
169
172
  );
173
+ } else {
174
+ const target = parseMappingTarget(m.repo);
175
+ if (!target) {
176
+ out.push(
177
+ mk(ERROR, "mapping-repo", at,
178
+ `mapping for "${m.workspace ?? "?"}" targets "${m.repo}" — \`repo\` must be a clean path under tenants/<id>/ (the reconcile refuses it)`,
179
+ `e.g. "repo": "tenants/${config.tenant ?? "<id>"}/content/"`),
180
+ );
181
+ } else {
182
+ // Coverage is about WHAT is mapped (the target), not where it lives.
183
+ seen.add(target.rel.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
184
+ seen.add(target.rel);
185
+ }
170
186
  }
171
187
  if (!KNOWN_KINDS.has(m.kind)) {
172
188
  out.push(
@@ -1091,16 +1107,9 @@ export function validateTenant(tenantDir, opts = {}) {
1091
1107
  /** @type {Finding[]} */
1092
1108
  const findings = [];
1093
1109
  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
1110
 
1103
- // 1. .tot/config.json — shape (flow-back readiness)
1111
+ // 1. .tot/config.json — shape (flow-back readiness). Read first: its mappings
1112
+ // say where theme.json / content/ / public/ live in this tree.
1104
1113
  const configPath = join(tenantDir, ".tot", "config.json");
1105
1114
  let config = null;
1106
1115
  if (existsSync(configPath)) {
@@ -1111,14 +1120,46 @@ export function validateTenant(tenantDir, opts = {}) {
1111
1120
  config = value;
1112
1121
  findings.push(...validateConfigShape(value, ".tot/config.json"));
1113
1122
  }
1114
- } else {
1123
+ }
1124
+ const located = (tenantRel) => join(tenantDir, workspacePathForTenantRelative(config, tenantRel));
1125
+ const contentDir = located("content");
1126
+ const publicDir = located("public");
1127
+ const pagesDir = join(contentDir, "pages-html");
1128
+
1129
+ // 0. basic layout
1130
+ if (!existsSync(contentDir)) {
1131
+ findings.push(mk(ERROR, "layout", rel(contentDir) + "/", `no ${rel(contentDir)}/ directory`));
1132
+ }
1133
+
1134
+ if (!existsSync(configPath)) {
1115
1135
  findings.push(
1116
1136
  mk(WARN, "config-absent", ".tot/config.json",
1117
1137
  "no .tot/config.json — tenant can serve but is NOT flow-back-ready (reconcile has nothing to map)"),
1118
1138
  );
1119
1139
  }
1120
1140
  const scope = opts.scope || config?.scope;
1121
- const activeTenantId = opts.tenantId || scope || config?.tenant;
1141
+ // The canonical tenant id: the one the asset rule binds to and the one the
1142
+ // mappings' targets resolve under — never a directory name.
1143
+ const activeTenantId = opts.tenantId || config?.tenant || scope;
1144
+ if (config && activeTenantId) {
1145
+ for (const f of foreignTenantSegments(config, activeTenantId)) {
1146
+ findings.push(
1147
+ mk(WARN, "mapping-tenant-segment", ".tot/config.json",
1148
+ `mapping for "${f.workspace}" targets "${f.repo}", naming tenant "${f.segment}" — this store is "${activeTenantId}". ` +
1149
+ `It still resolves under tenants/${activeTenantId}/ (the segment is not read), and its assets are served at /tenants/${activeTenantId}/...`,
1150
+ `write "repo": "tenants/${activeTenantId}/${parseMappingTarget(f.repo)?.rel ?? ""}" so the config agrees with itself ` +
1151
+ `(the agency kit's tools/repair-tenant-config.mjs rewrites the whole config)`),
1152
+ );
1153
+ }
1154
+ }
1155
+ // Publication refuses a config whose `scope` is not the store's id.
1156
+ if (config && typeof config.tenant === "string" && config.scope !== config.tenant) {
1157
+ findings.push(
1158
+ mk(WARN, "config-scope-mismatch", ".tot/config.json",
1159
+ `"scope" is ${JSON.stringify(config.scope ?? null)} but "tenant" is "${config.tenant}" — publication refuses a config whose scope is not the store's id`,
1160
+ `set "scope" to "${config.tenant}" (the agency kit's tools/repair-tenant-config.mjs rewrites the whole config)`),
1161
+ );
1162
+ }
1122
1163
  // platform-route awareness: which framework routes the host platform owns for this tenant
1123
1164
  const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
1124
1165
  const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
@@ -1128,18 +1169,18 @@ export function validateTenant(tenantDir, opts = {}) {
1128
1169
  }
1129
1170
 
1130
1171
  // 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
1131
- const themePath = join(tenantDir, "theme.json");
1172
+ const themePath = located("theme.json");
1132
1173
  if (existsSync(themePath)) {
1133
1174
  const { error } = readJsonSafe(themePath);
1134
1175
  if (error) {
1135
1176
  findings.push(
1136
- mk(ERROR, "theme-parse", "theme.json",
1177
+ mk(ERROR, "theme-parse", rel(themePath),
1137
1178
  `invalid JSON: ${error}`, "theme.json is loaded by an eager Vite glob — a parse error fails the entire storefront build"),
1138
1179
  );
1139
1180
  }
1140
1181
  }
1141
1182
 
1142
- const capabilitiesPath = join(tenantDir, "capabilities.json");
1183
+ const capabilitiesPath = located("capabilities.json");
1143
1184
  if (existsSync(capabilitiesPath)) {
1144
1185
  const { value, error } = readJsonSafe(capabilitiesPath);
1145
1186
  if (error) {
@@ -1149,7 +1190,7 @@ export function validateTenant(tenantDir, opts = {}) {
1149
1190
  }
1150
1191
  }
1151
1192
 
1152
- const scriptsPath = join(tenantDir, "scripts.json");
1193
+ const scriptsPath = located("scripts.json");
1153
1194
  if (existsSync(scriptsPath)) {
1154
1195
  const { value, error } = readJsonSafe(scriptsPath);
1155
1196
  if (error) {
@@ -1159,7 +1200,7 @@ export function validateTenant(tenantDir, opts = {}) {
1159
1200
  }
1160
1201
  }
1161
1202
 
1162
- const embedsPath = join(tenantDir, "embeds.json");
1203
+ const embedsPath = located("embeds.json");
1163
1204
  if (existsSync(embedsPath)) {
1164
1205
  const { value, error } = readJsonSafe(embedsPath);
1165
1206
  if (error) {
@@ -1259,9 +1300,7 @@ export function validateTenant(tenantDir, opts = {}) {
1259
1300
  // content/ plus the root config files.
1260
1301
  const conflictScanFiles = [
1261
1302
  ...walk(contentDir, hasScanExt),
1262
- ...["theme.json", "capabilities.json", "scripts.json", join(".tot", "config.json")]
1263
- .map((f) => join(tenantDir, f))
1264
- .filter((p) => existsSync(p)),
1303
+ ...[themePath, capabilitiesPath, scriptsPath, configPath].filter((p) => existsSync(p)),
1265
1304
  ];
1266
1305
  for (const p of conflictScanFiles) {
1267
1306
  const lines = detectConflictMarkers(readFileSync(p, "utf8"));
@@ -1280,9 +1319,8 @@ export function validateTenant(tenantDir, opts = {}) {
1280
1319
  // origin, is blocking; advisory (info) when there's no embeds.json to compare.
1281
1320
  {
1282
1321
  let declaredEmbeds = null;
1283
- const embedsFile = join(tenantDir, "embeds.json");
1284
- if (existsSync(embedsFile)) {
1285
- const { value } = readJsonSafe(embedsFile);
1322
+ if (existsSync(embedsPath)) {
1323
+ const { value } = readJsonSafe(embedsPath);
1286
1324
  if (Array.isArray(value)) declaredEmbeds = new Set(value.filter((s) => typeof s === "string"));
1287
1325
  }
1288
1326
  for (const file of walk(contentDir, (p) => p.endsWith(".html"))) {