@tokenoftrust/cli 2.2.12 → 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 +1 -1
- package/src/commands/dev.mjs +30 -6
- package/src/renderer-lock.mjs +74 -0
- package/src/tot-repo-mappings.mjs +153 -0
- package/src/validate.mjs +134 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.2.
|
|
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",
|
package/src/commands/dev.mjs
CHANGED
|
@@ -51,6 +51,7 @@ import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
|
|
|
51
51
|
import { streamDevLogs } from "../dev-logs.mjs";
|
|
52
52
|
import { cockpitUrlFrom } from "../banner.mjs";
|
|
53
53
|
import { recordDiagnostic } from "../diagnostics.mjs";
|
|
54
|
+
import { lockRendererDir, unlockRendererDir, rendererDirInUse } from "../renderer-lock.mjs";
|
|
54
55
|
|
|
55
56
|
// Kept for the internal `tot start` migration path. `tot dev` itself is native-only.
|
|
56
57
|
const DEFAULT_DEV_IMAGE =
|
|
@@ -914,8 +915,19 @@ export function rendererArtifactComplete(runnerDir) {
|
|
|
914
915
|
* runner tree per version forever. Only touches `public-*` dirs: the entitled
|
|
915
916
|
* path caches by bare version and sample tarball runs by `sample-*`, both left
|
|
916
917
|
* alone. Best-effort — a failure here never breaks `tot dev`.
|
|
918
|
+
*
|
|
919
|
+
* NEVER deletes a dir a running `tot dev` session is still serving files out
|
|
920
|
+
* of (per renderer-lock.mjs's cross-process lock) — resolving a newer runner
|
|
921
|
+
* in one terminal must not crash a DIFFERENT, still-running session in
|
|
922
|
+
* another with ENOENT reading its own astro templates mid-session
|
|
923
|
+
* (fb-1791304494289-ussj8l). That dir is simply left for a later prune once
|
|
924
|
+
* nothing holds it; `log` reports the deferral instead of pruning silently.
|
|
917
925
|
*/
|
|
918
|
-
function prunePublicRunnerCache(
|
|
926
|
+
export function prunePublicRunnerCache(
|
|
927
|
+
cacheRoot,
|
|
928
|
+
keepVersion,
|
|
929
|
+
{ log = (/** @type {string} */ _message) => {}, pidAlive = processAlive, lockDir = undefined } = {},
|
|
930
|
+
) {
|
|
919
931
|
try {
|
|
920
932
|
if (!cacheRoot || !existsSync(cacheRoot)) return;
|
|
921
933
|
// keepVersion == null → keep NOTHING (drop every public-* dir); used when a
|
|
@@ -923,7 +935,12 @@ function prunePublicRunnerCache(cacheRoot, keepVersion) {
|
|
|
923
935
|
const keep = keepVersion == null ? null : `public-${keepVersion}`;
|
|
924
936
|
for (const name of readdirSync(cacheRoot)) {
|
|
925
937
|
if (!name.startsWith("public-") || name === keep) continue;
|
|
926
|
-
|
|
938
|
+
const dir = join(cacheRoot, name);
|
|
939
|
+
if (rendererDirInUse(dir, { pidAlive, ...(lockDir ? { dir: lockDir } : {}) })) {
|
|
940
|
+
log(`~ keeping ${name} cached — a running \`tot dev\` session is still serving from it.`);
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
rmSync(dir, { recursive: true, force: true });
|
|
927
944
|
}
|
|
928
945
|
} catch {
|
|
929
946
|
/* best-effort cache hygiene */
|
|
@@ -1044,21 +1061,21 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
1044
1061
|
`~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
|
|
1045
1062
|
);
|
|
1046
1063
|
setRunnerVersion(wantVersion);
|
|
1047
|
-
prunePublicRunnerCache(cacheRoot, wantVersion);
|
|
1064
|
+
prunePublicRunnerCache(cacheRoot, wantVersion, { log: console.error });
|
|
1048
1065
|
return exact;
|
|
1049
1066
|
}
|
|
1050
1067
|
// Same version string, different published contents — a corrected
|
|
1051
1068
|
// republish. Fall through to resolution + a fresh install (which also
|
|
1052
1069
|
// verifies the new bytes against the new integrity).
|
|
1053
1070
|
console.error(`~ renderer: ${wantVersion} was republished with different contents — refetching the corrected artifact`);
|
|
1054
|
-
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none matches what npm now publishes
|
|
1071
|
+
prunePublicRunnerCache(cacheRoot, null, { log: console.error }); // drop ALL public-* — none matches what npm now publishes
|
|
1055
1072
|
} else {
|
|
1056
1073
|
console.error(
|
|
1057
1074
|
rendererCacheHealthy(exact)
|
|
1058
1075
|
? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
|
|
1059
1076
|
: `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
|
|
1060
1077
|
);
|
|
1061
|
-
prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
|
|
1078
|
+
prunePublicRunnerCache(cacheRoot, null, { log: console.error }); // drop ALL public-* — none is trustworthy
|
|
1062
1079
|
}
|
|
1063
1080
|
}
|
|
1064
1081
|
}
|
|
@@ -1096,7 +1113,7 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
|
|
|
1096
1113
|
setRunnerVersion(pub.version); // telemetry: the runner version running this session
|
|
1097
1114
|
// The pinned version is now installed under public-<version> — drop any other
|
|
1098
1115
|
// public-* dirs (e.g. the stale public-0.1.0) so they can never be reused.
|
|
1099
|
-
prunePublicRunnerCache(cacheRoot, pub.version);
|
|
1116
|
+
prunePublicRunnerCache(cacheRoot, pub.version, { log: console.error });
|
|
1100
1117
|
return dir;
|
|
1101
1118
|
}
|
|
1102
1119
|
|
|
@@ -1715,14 +1732,21 @@ export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit",
|
|
|
1715
1732
|
[script, "--workspace", workspace, "--port", port],
|
|
1716
1733
|
{ cwd: runnerDir, stdio: stdioArr, env: mergedEnv },
|
|
1717
1734
|
);
|
|
1735
|
+
// Claim this runnerDir for the lifetime of this child — prunePublicRunnerCache
|
|
1736
|
+
// consults this lock before deleting an un-pinned cached version, so resolving
|
|
1737
|
+
// a NEWER runner in one terminal can never delete the directory THIS session
|
|
1738
|
+
// is still serving files out of (fb-1791304494289-ussj8l).
|
|
1739
|
+
lockRendererDir(runnerDir, { pid: child.pid });
|
|
1718
1740
|
const handle = { child, exited: false, done: /** @type {any} */ (null) };
|
|
1719
1741
|
handle.done = new Promise((resolvePromise) => {
|
|
1720
1742
|
child.on("exit", (code) => {
|
|
1721
1743
|
handle.exited = true;
|
|
1744
|
+
unlockRendererDir({ pid: child.pid });
|
|
1722
1745
|
resolvePromise(code ?? 0);
|
|
1723
1746
|
});
|
|
1724
1747
|
child.on("error", (e) => {
|
|
1725
1748
|
handle.exited = true;
|
|
1749
|
+
unlockRendererDir({ pid: child.pid });
|
|
1726
1750
|
console.error(fail(`could not start the native dev runner: ${e.message}`));
|
|
1727
1751
|
resolvePromise(1);
|
|
1728
1752
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Cross-process lock recording which PIDs are actively serving a `tot dev`
|
|
4
|
+
* native session out of which cached renderer directory (`~/.tot/cache/renderer/
|
|
5
|
+
* <version>` or `public-<version>`). `prunePublicRunnerCache` (src/commands/dev.mjs)
|
|
6
|
+
* reads it before deleting an un-pinned cached version, so resolving a NEWER
|
|
7
|
+
* runner in one terminal can never delete the directory a DIFFERENT, still-running
|
|
8
|
+
* `tot dev` session in another terminal is serving files out of — which used to
|
|
9
|
+
* crash the live session with ENOENT reading its own astro templates mid-session
|
|
10
|
+
* (fb-1791304494289-ussj8l).
|
|
11
|
+
*
|
|
12
|
+
* Lives OUTSIDE the renderer cache tree itself (not `<runnerDir>/.tot-dev-lock`)
|
|
13
|
+
* so a lock entry is never mistaken for cache/package content and never ships
|
|
14
|
+
* inside a published runner tarball.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
export const RENDERER_LOCK_DIR = join(homedir(), ".tot", "run", "active-renderers");
|
|
21
|
+
|
|
22
|
+
/** Record that `pid` (default: this process) is actively serving from `runnerDir`. */
|
|
23
|
+
export function lockRendererDir(runnerDir, { pid = process.pid, dir = RENDERER_LOCK_DIR } = {}) {
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
writeFileSync(join(dir, String(pid)), runnerDir);
|
|
27
|
+
} catch {
|
|
28
|
+
/* best-effort — a failed lock must never block `tot dev` from starting */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Release a lock (best-effort; safe to call more than once, e.g. on every exit path). */
|
|
33
|
+
export function unlockRendererDir({ pid = process.pid, dir = RENDERER_LOCK_DIR } = {}) {
|
|
34
|
+
try {
|
|
35
|
+
rmSync(join(dir, String(pid)), { force: true });
|
|
36
|
+
} catch {
|
|
37
|
+
/* best-effort */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Is any LIVE process's lock pointing at `runnerDir`? Fails CLOSED (true) on any
|
|
43
|
+
* read error — an unreadable lock state must never read as "safe to delete",
|
|
44
|
+
* since that's precisely the failure this exists to prevent. A stale (dead-pid)
|
|
45
|
+
* lock is ignored so cache hygiene still reclaims disk once nothing holds it.
|
|
46
|
+
* @param {string} runnerDir
|
|
47
|
+
* @param {{ pidAlive: (pid:number)=>boolean, dir?: string }} deps
|
|
48
|
+
*/
|
|
49
|
+
export function rendererDirInUse(runnerDir, { pidAlive, dir = RENDERER_LOCK_DIR }) {
|
|
50
|
+
try {
|
|
51
|
+
if (!existsSync(dir)) return false;
|
|
52
|
+
let inUse = false;
|
|
53
|
+
for (const name of readdirSync(dir)) {
|
|
54
|
+
const pid = Number(name);
|
|
55
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
56
|
+
let lockedDir;
|
|
57
|
+
try {
|
|
58
|
+
lockedDir = readFileSync(join(dir, name), "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
continue; // one unreadable lock entry must not block the rest of the scan
|
|
61
|
+
}
|
|
62
|
+
if (pidAlive(pid)) {
|
|
63
|
+
if (lockedDir === runnerDir) inUse = true;
|
|
64
|
+
} else {
|
|
65
|
+
// A crashed/killed session never ran its own exit handler — reap its
|
|
66
|
+
// lock opportunistically (best-effort) so entries don't accumulate.
|
|
67
|
+
try { rmSync(join(dir, name), { force: true }); } catch { /* best-effort */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return inUse;
|
|
71
|
+
} catch {
|
|
72
|
+
return true; // fail closed: an unreadable lock DIRECTORY must not read as "unused"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -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,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";
|
|
@@ -63,9 +68,74 @@ const hasScanExt = (p) => CONFLICT_SCAN_EXT.has((p.match(/\.[^./\\]+$/) || [""])
|
|
|
63
68
|
const KNOWN_KINDS = new Set(["file", "tree"]);
|
|
64
69
|
const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
|
|
65
70
|
|
|
71
|
+
// The keys the platform reads in `.tot/config.json`. Mirrors
|
|
72
|
+
// packages/public-runtime/src/declared-tenant-config.mjs (this package ships to npm
|
|
73
|
+
// on its own); test/declared-config-parity.test.mjs pins the two together, so a
|
|
74
|
+
// field the platform honors can never be refused here, and a key no surface reads
|
|
75
|
+
// can never pass.
|
|
76
|
+
export const TOT_CONFIG_KEYS = [
|
|
77
|
+
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
78
|
+
"siteType", "compliance", "capabilities", "features",
|
|
79
|
+
];
|
|
80
|
+
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
81
|
+
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
82
|
+
export const COMPLIANCE_KEYS = [
|
|
83
|
+
"ruleProfile", "minAge", "nicotineWarning", "shippingRestriction", "pactAct",
|
|
84
|
+
"adultSignature", "prop65", "purchaseLimit", "stateEligibility", "exciseTax",
|
|
85
|
+
];
|
|
86
|
+
export const FEATURE_KEYS = [
|
|
87
|
+
"catalog", "productSearch", "savedItems", "quickView", "newsletter", "commerceSitemap", "productJsonLd", "ageGate",
|
|
88
|
+
];
|
|
89
|
+
export const SITE_TYPES = ["commerce", "marketing"];
|
|
90
|
+
|
|
91
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Refuse what the platform would silently ignore: an unknown top-level key, an
|
|
95
|
+
* unknown `compliance`/`features` key, or a declared field of the wrong shape. Each
|
|
96
|
+
* one passes JSON parsing and renders nothing — on a regulated store that is a
|
|
97
|
+
* mandated notice that never appears.
|
|
98
|
+
* @returns {Finding[]}
|
|
99
|
+
*/
|
|
100
|
+
export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const key of Object.keys(config)) {
|
|
103
|
+
if (!TOT_CONFIG_KEYS.includes(key)) {
|
|
104
|
+
const renamed = RENAMED_CONFIG_KEYS[key];
|
|
105
|
+
out.push(mk(ERROR, "config-unknown-key", file,
|
|
106
|
+
`\`${key}\` is not a .tot/config.json key — the platform never reads it`,
|
|
107
|
+
renamed ? `use \`${renamed}\`` : `known keys: ${TOT_CONFIG_KEYS.join(", ")}`));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (config.siteType !== undefined && !SITE_TYPES.includes(config.siteType)) {
|
|
111
|
+
out.push(mk(ERROR, "config-site-type", file, `\`siteType\` is "${config.siteType}" — must be one of ${SITE_TYPES.join(", ")}`));
|
|
112
|
+
}
|
|
113
|
+
/** @type {Array<[string, string[]]>} */
|
|
114
|
+
const objectFields = [["compliance", COMPLIANCE_KEYS], ["features", FEATURE_KEYS]];
|
|
115
|
+
for (const [field, known] of objectFields) {
|
|
116
|
+
const value = config[field];
|
|
117
|
+
if (value === undefined) continue;
|
|
118
|
+
if (!isObject(value)) {
|
|
119
|
+
out.push(mk(ERROR, `config-${field}`, file, `\`${field}\` must be an object`));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
for (const key of Object.keys(value)) {
|
|
123
|
+
if (!known.includes(key)) {
|
|
124
|
+
out.push(mk(ERROR, `config-${field}-unknown-key`, `${file} ${field}`,
|
|
125
|
+
`\`${field}.${key}\` is not a ${field} key — nothing renders from it`,
|
|
126
|
+
`known keys: ${known.join(", ")}`));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (config.capabilities !== undefined && !isObject(config.capabilities)) {
|
|
131
|
+
out.push(mk(ERROR, "config-capabilities", file, "`capabilities` must be an object"));
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
66
136
|
/**
|
|
67
137
|
* Validate a parsed `.tot/config.json` against the shape the reconcile pipeline
|
|
68
|
-
* (`
|
|
138
|
+
* (`validateMappingShape`) requires. Pure — no I/O.
|
|
69
139
|
* @returns {Finding[]}
|
|
70
140
|
*/
|
|
71
141
|
export function validateConfigShape(config, file = ".tot/config.json") {
|
|
@@ -76,6 +146,7 @@ export function validateConfigShape(config, file = ".tot/config.json") {
|
|
|
76
146
|
if (typeof config.tenant !== "string" || !config.tenant) {
|
|
77
147
|
out.push(mk(ERROR, "config-tenant", file, "`tenant` must be a non-empty string"));
|
|
78
148
|
}
|
|
149
|
+
out.push(...validateDeclaredConfig(config, file));
|
|
79
150
|
if (!Array.isArray(config.mappings)) {
|
|
80
151
|
out.push(mk(ERROR, "config-mappings", file, "`mappings` must be an array", "seed shape: [{workspace,repo,kind}]"));
|
|
81
152
|
return out;
|
|
@@ -89,18 +160,29 @@ export function validateConfigShape(config, file = ".tot/config.json") {
|
|
|
89
160
|
}
|
|
90
161
|
if (typeof m.workspace !== "string" || !m.workspace) {
|
|
91
162
|
out.push(mk(ERROR, "mapping-workspace", at, "`workspace` must be a non-empty string"));
|
|
92
|
-
} else {
|
|
93
|
-
seen.add(m.workspace.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
|
|
94
|
-
seen.add(m.workspace);
|
|
95
163
|
}
|
|
96
|
-
// THE regression guard: reconcile
|
|
97
|
-
// knows "file" vs tree — a missing repo or
|
|
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.
|
|
98
167
|
if (typeof m.repo !== "string" || !m.repo) {
|
|
99
168
|
out.push(
|
|
100
169
|
mk(ERROR, "mapping-repo", at,
|
|
101
|
-
`mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (reconcile
|
|
170
|
+
`mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (the reconcile refuses it)`,
|
|
102
171
|
`add "repo": "tenants/${config.tenant ?? "<id>"}/${(m.workspace ?? "").replace(/\/+$/, "")}${m.kind === "file" ? "" : "/"}"`),
|
|
103
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
|
+
}
|
|
104
186
|
}
|
|
105
187
|
if (!KNOWN_KINDS.has(m.kind)) {
|
|
106
188
|
out.push(
|
|
@@ -1025,16 +1107,9 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1025
1107
|
/** @type {Finding[]} */
|
|
1026
1108
|
const findings = [];
|
|
1027
1109
|
const rel = (p) => relative(tenantDir, p) || p;
|
|
1028
|
-
const contentDir = join(tenantDir, "content");
|
|
1029
|
-
const publicDir = join(tenantDir, "public");
|
|
1030
|
-
const pagesDir = join(contentDir, "pages-html");
|
|
1031
1110
|
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
findings.push(mk(ERROR, "layout", "content/", "no content/ directory"));
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
// 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.
|
|
1038
1113
|
const configPath = join(tenantDir, ".tot", "config.json");
|
|
1039
1114
|
let config = null;
|
|
1040
1115
|
if (existsSync(configPath)) {
|
|
@@ -1045,14 +1120,46 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1045
1120
|
config = value;
|
|
1046
1121
|
findings.push(...validateConfigShape(value, ".tot/config.json"));
|
|
1047
1122
|
}
|
|
1048
|
-
}
|
|
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)) {
|
|
1049
1135
|
findings.push(
|
|
1050
1136
|
mk(WARN, "config-absent", ".tot/config.json",
|
|
1051
1137
|
"no .tot/config.json — tenant can serve but is NOT flow-back-ready (reconcile has nothing to map)"),
|
|
1052
1138
|
);
|
|
1053
1139
|
}
|
|
1054
1140
|
const scope = opts.scope || config?.scope;
|
|
1055
|
-
|
|
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
|
+
}
|
|
1056
1163
|
// platform-route awareness: which framework routes the host platform owns for this tenant
|
|
1057
1164
|
const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
|
|
1058
1165
|
const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
|
|
@@ -1062,18 +1169,18 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1062
1169
|
}
|
|
1063
1170
|
|
|
1064
1171
|
// 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
|
|
1065
|
-
const themePath =
|
|
1172
|
+
const themePath = located("theme.json");
|
|
1066
1173
|
if (existsSync(themePath)) {
|
|
1067
1174
|
const { error } = readJsonSafe(themePath);
|
|
1068
1175
|
if (error) {
|
|
1069
1176
|
findings.push(
|
|
1070
|
-
mk(ERROR, "theme-parse",
|
|
1177
|
+
mk(ERROR, "theme-parse", rel(themePath),
|
|
1071
1178
|
`invalid JSON: ${error}`, "theme.json is loaded by an eager Vite glob — a parse error fails the entire storefront build"),
|
|
1072
1179
|
);
|
|
1073
1180
|
}
|
|
1074
1181
|
}
|
|
1075
1182
|
|
|
1076
|
-
const capabilitiesPath =
|
|
1183
|
+
const capabilitiesPath = located("capabilities.json");
|
|
1077
1184
|
if (existsSync(capabilitiesPath)) {
|
|
1078
1185
|
const { value, error } = readJsonSafe(capabilitiesPath);
|
|
1079
1186
|
if (error) {
|
|
@@ -1083,7 +1190,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1083
1190
|
}
|
|
1084
1191
|
}
|
|
1085
1192
|
|
|
1086
|
-
const scriptsPath =
|
|
1193
|
+
const scriptsPath = located("scripts.json");
|
|
1087
1194
|
if (existsSync(scriptsPath)) {
|
|
1088
1195
|
const { value, error } = readJsonSafe(scriptsPath);
|
|
1089
1196
|
if (error) {
|
|
@@ -1093,7 +1200,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1093
1200
|
}
|
|
1094
1201
|
}
|
|
1095
1202
|
|
|
1096
|
-
const embedsPath =
|
|
1203
|
+
const embedsPath = located("embeds.json");
|
|
1097
1204
|
if (existsSync(embedsPath)) {
|
|
1098
1205
|
const { value, error } = readJsonSafe(embedsPath);
|
|
1099
1206
|
if (error) {
|
|
@@ -1193,9 +1300,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1193
1300
|
// content/ plus the root config files.
|
|
1194
1301
|
const conflictScanFiles = [
|
|
1195
1302
|
...walk(contentDir, hasScanExt),
|
|
1196
|
-
...[
|
|
1197
|
-
.map((f) => join(tenantDir, f))
|
|
1198
|
-
.filter((p) => existsSync(p)),
|
|
1303
|
+
...[themePath, capabilitiesPath, scriptsPath, configPath].filter((p) => existsSync(p)),
|
|
1199
1304
|
];
|
|
1200
1305
|
for (const p of conflictScanFiles) {
|
|
1201
1306
|
const lines = detectConflictMarkers(readFileSync(p, "utf8"));
|
|
@@ -1214,9 +1319,8 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
1214
1319
|
// origin, is blocking; advisory (info) when there's no embeds.json to compare.
|
|
1215
1320
|
{
|
|
1216
1321
|
let declaredEmbeds = null;
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
const { value } = readJsonSafe(embedsFile);
|
|
1322
|
+
if (existsSync(embedsPath)) {
|
|
1323
|
+
const { value } = readJsonSafe(embedsPath);
|
|
1220
1324
|
if (Array.isArray(value)) declaredEmbeds = new Set(value.filter((s) => typeof s === "string"));
|
|
1221
1325
|
}
|
|
1222
1326
|
for (const file of walk(contentDir, (p) => p.endsWith(".html"))) {
|