@tokenoftrust/storefront-runner 2.2.89 → 2.2.90

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.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A store's own catalog files — the cold content artifact of ADR 0033 — typed for the platform.
3
+ *
4
+ * The layout, the validator and the parser live once, in the agency kit's pure catalog module
5
+ * (`agency-kit/tools/lib/catalog-model/files.mjs`), which the kit's own tools, the renderer, the
6
+ * reconcile validator and the migration engine all import. This module only narrows its records
7
+ * to the canonical types in `./product.ts`; the kit's model is pinned to those types field for
8
+ * field by `scripts/lib/agency-kit-catalog-drift.test.mjs`.
9
+ */
10
+ import {
11
+ readCatalogFile as readKitCatalogFile,
12
+ readCatalogFiles as readKitCatalogFiles,
13
+ type CatalogStore,
14
+ } from "../../../agency-kit/tools/lib/catalog-model/files.mjs";
15
+ import type { CatalogCollection, CatalogProduct } from "./product.js";
16
+
17
+ export {
18
+ CATALOG_ARTIFACT_PREFIX,
19
+ CATALOG_COLLECTIONS_PREFIX,
20
+ CATALOG_CONTENT_DIRS,
21
+ CATALOG_HANDLE_RE,
22
+ CATALOG_PRODUCTS_PREFIX,
23
+ CatalogFileError,
24
+ catalogFileIssues,
25
+ classifyCatalogPath,
26
+ declaredAvailability,
27
+ planCatalogFiles,
28
+ type CatalogFileKind,
29
+ type CatalogStore,
30
+ type InventoryProvider,
31
+ } from "../../../agency-kit/tools/lib/catalog-model/files.mjs";
32
+
33
+ export type CatalogFileRecord =
34
+ | { kind: "product"; record: CatalogProduct }
35
+ | { kind: "collection"; record: CatalogCollection };
36
+
37
+ /** One validated catalog file as its canonical record. Throws `CatalogFileError`. */
38
+ export function readCatalogFile(relative: string, raw: string, store: CatalogStore): CatalogFileRecord {
39
+ return readKitCatalogFile(relative, raw, store) as unknown as CatalogFileRecord;
40
+ }
41
+
42
+ /** A whole validated catalog. Throws `CatalogFileError` naming every failing file. */
43
+ export function readCatalogFiles(
44
+ entries: Iterable<{ path: string; raw: string | null }>,
45
+ store: CatalogStore,
46
+ ): { products: CatalogProduct[]; collections: CatalogCollection[] } {
47
+ return readKitCatalogFiles(entries, store) as unknown as {
48
+ products: CatalogProduct[];
49
+ collections: CatalogCollection[];
50
+ };
51
+ }
@@ -622,6 +622,25 @@ export interface TenantConfig {
622
622
  */
623
623
  htmlCachePolicy?: HtmlCachePolicy;
624
624
  };
625
+ /**
626
+ * Catalog serving (ADR 0033). The store's own `content/catalog/` files are the
627
+ * system of record; this declares which artifact serves reads.
628
+ *
629
+ * `"files"` (the default): the store's catalog files, then the platform's
630
+ * grandfathered in-repo snapshot for the same data scope, then an empty catalog.
631
+ * `"d1"`: the derived D1 serving projection, read on the control plane only.
632
+ * Never inferred from a tenant id.
633
+ */
634
+ catalog?: {
635
+ serving?: "files" | "d1";
636
+ };
637
+ /**
638
+ * Availability (hot) provider (ADR 0033). Availability is never read from catalog
639
+ * content files. Absent ⇒ `"mirror"`, the only implemented provider.
640
+ */
641
+ inventory?: {
642
+ provider: "mirror" | "live-hydrate" | "tot-authoritative";
643
+ };
625
644
  /**
626
645
  * Storefront product/subscription state — the paywall the platform gates GO-LIVE
627
646
  * on. This is the storefront's
@@ -1,24 +1,46 @@
1
1
  // @ts-check
2
2
  /**
3
3
  * Pure planning for `tot dev --workspace` grafts: which checkout-relative paths
4
- * get copied into the in-tree `tenants/<id>/` layout the app globs, and whether
5
- * each is a file or a directory tree. Decision-only — scripts/tot-dev.mjs does
6
- * the actual fs copy/backup/watch effects per planned entry.
4
+ * get copied where in the in-tree `tenants/<id>/` layout the app globs, and
5
+ * whether each is a file or a directory tree. Decision-only — scripts/tot-dev.mjs
6
+ * does the actual fs copy/backup/watch effects per planned entry.
7
+ *
8
+ * A mapping's `workspace` is where the files live in the checkout; its `repo`
9
+ * names what they are, tenant-relative (`theme.json`, `content/`, …). They land
10
+ * under THIS tenant's directory — the same resolution the hosted reconcile uses
11
+ * (agency-kit/tools/lib/tot-repo-mappings.mjs), whatever tenant segment `repo`
12
+ * spells — so the dev server serves them at the same `/tenants/<tenant>/…` the
13
+ * hosted preview does.
7
14
  */
15
+ import { tenantDirRelative } from "../lib/tenant-dirs.mjs";
16
+ import { parseMappingTarget } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
17
+
18
+ const TOT_CONFIG = ".tot/config.json";
8
19
 
9
20
  /**
10
- * @param {Array<{workspace?: unknown, kind?: unknown}>} mappings the checkout's
11
- * own `.tot/config.json` `mappings` array (content/, public/, theme.json, …)
12
- * @returns {{ workspace: string, isDir: boolean }[]}
21
+ * @param {string} tenant canonical tenant id (the checkout config's `tenant`)
22
+ * @param {unknown} mappings the checkout's own `.tot/config.json` `mappings` array
23
+ * (content/, public/, theme.json, …)
24
+ * @returns {{ workspace: string, target: string | null, isDir: boolean }[]} checkout-relative
25
+ * `workspace` and repo-relative `target` (POSIX); `target` is null for a mapping whose `repo` is
26
+ * not a valid `tenants/<id>/<path>` target, which the caller skips
13
27
  */
14
- export function standaloneGraftPlan(mappings) {
28
+ export function standaloneGraftPlan(tenant, mappings) {
29
+ const root = tenantDirRelative(tenant);
15
30
  const plan = [];
16
31
  const seen = new Set();
17
32
  for (const map of Array.isArray(mappings) ? mappings : []) {
18
33
  const workspace = String(map?.workspace ?? "").replace(/\/+$/, "");
19
34
  if (!workspace || seen.has(workspace)) continue;
20
35
  seen.add(workspace);
21
- plan.push({ workspace, isDir: map?.kind !== "file" });
36
+ if (workspace === TOT_CONFIG) {
37
+ plan.push({ workspace, target: `${root}/${TOT_CONFIG}`, isDir: false });
38
+ continue;
39
+ }
40
+ const declared = parseMappingTarget(map?.repo);
41
+ const rel = declared ? declared.rel.replace(/\/+$/, "") : null;
42
+ const target = rel === null ? null : rel ? `${root}/${rel}` : root;
43
+ plan.push({ workspace, target, isDir: map?.kind !== "file" });
22
44
  }
23
45
  // `.tot/config.json` is never itself one of the declared `mappings` (those map
24
46
  // content/public/theme.json) but IS the tenant's own declared platform config
@@ -28,8 +50,8 @@ export function standaloneGraftPlan(mappings) {
28
50
  // scripts/tenant/materialize-tenant-content.mjs copies for a Gitea-sourced
29
51
  // tenant. Graft it unconditionally so a regulated store's mandated notices
30
52
  // resolve from the SAME declared config locally as they would once published.
31
- if (!seen.has(".tot/config.json")) {
32
- plan.push({ workspace: ".tot/config.json", isDir: false });
53
+ if (!seen.has(TOT_CONFIG)) {
54
+ plan.push({ workspace: TOT_CONFIG, target: `${root}/${TOT_CONFIG}`, isDir: false });
33
55
  }
34
56
  return plan;
35
57
  }
@@ -71,6 +71,7 @@ import { tmpdir } from "node:os";
71
71
  import { dirname, join, resolve, sep } from "node:path";
72
72
  import { fileURLToPath, pathToFileURL } from "node:url";
73
73
  import { tenantDirRelative } from "../lib/tenant-dirs.mjs";
74
+ import { parseMappingTarget } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
74
75
 
75
76
  const here = dirname(fileURLToPath(import.meta.url));
76
77
  const repoRoot = resolve(here, "..", "..");
@@ -153,7 +154,13 @@ function canonicalMappings(tenant) {
153
154
  */
154
155
  export function copyMapping({ checkoutDir, root, tenant, mapping, log = (/** @type {string} */ _m) => {} }) {
155
156
  const src = join(checkoutDir, mapping.workspace.replace(/\/+$/, ""));
156
- const target = resolveTargetPath(root, tenant, mapping.repo.replace(/\/+$/, ""));
157
+ // The mapping names WHAT the files are (its tenant-relative target); the
158
+ // tenant being materialized names WHOSE — never the target's tenant segment.
159
+ const declared = parseMappingTarget(mapping.repo);
160
+ if (!declared) {
161
+ throw new Error(`mapping target "${mapping.repo}" escapes tenants/${tenant}/ — refusing`);
162
+ }
163
+ const target = resolveTargetPath(root, tenant, `tenants/${tenant}/${declared.rel}`.replace(/\/+$/, ""));
157
164
  if (target === resolve(root, tenantDirRelative(tenant))) {
158
165
  throw new Error("mapping target cannot replace the tenant root");
159
166
  }
@@ -5,7 +5,7 @@
5
5
  * Catches, BEFORE a commit reaches the hosted storefront, the classes of tenant
6
6
  * defect that otherwise fail silently or as a cryptic runtime/reconcile error:
7
7
  * · `.tot/config.json` mapping-shape drift (the #24/#25 regression — a `kind:"dir"`
8
- * / missing-`repo` mapping makes the flow-back reconcile `mapToArtifact` throw)
8
+ * / missing-`repo` mapping makes the flow-back reconcile refuse it)
9
9
  * · malformed `theme.json` / `home.json` / `chrome.json` (an eager Vite glob turns
10
10
  * these into a WHOLE-APP BUILD FAILURE)
11
11
  * · raw HTML that isn't a servable document, or drops CSS under the CSP nonce
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Design principle — "valid locally ≡ will reconcile in storefront": the checks below
17
17
  * mirror the reconcile pipeline's own gate (`@tot/private-controlplane`
18
- * customization-reconcile.ts `mapToArtifact` / `validateRawHtml`). Keep them in step.
18
+ * customization-reconcile.ts `validateMappingShape` / `validateRawHtml`, and the shared mapping resolver `tot-repo-mappings.mjs`). Keep them in step.
19
19
  *
20
20
  * Dependency-free. Two modes (mirrors scripts/tot-dev.mjs):
21
21
  * node scripts/tenant/validate.mjs <tenant> # monorepo: tenants/<tenant>/
@@ -38,6 +38,11 @@ import {
38
38
  isSafeRehostedAssetPathForTenant,
39
39
  isSafeStorefrontHref,
40
40
  } from "../../packages/public-runtime/src/extension-contract-values.mjs";
41
+ import {
42
+ foreignTenantSegments,
43
+ parseMappingTarget,
44
+ workspacePathForTenantRelative,
45
+ } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
41
46
  import { validateVisualParityClosure } from "../../packages/cli/src/visual-parity.mjs";
42
47
 
43
48
  /** Catalogued embed slugs a tenant may opt into (mirror of EMBED_PROVIDERS). */
@@ -97,7 +102,7 @@ const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
97
102
 
98
103
  /**
99
104
  * Validate a parsed `.tot/config.json` against the shape the reconcile pipeline
100
- * (`mapToArtifact`) requires. Pure — no I/O — so the CI regression test can call it.
105
+ * (`validateMappingShape`) requires. Pure — no I/O — so the CI regression test can call it.
101
106
  * @param {any} config parsed JSON
102
107
  * @param {string} file path for messages
103
108
  * @returns {Finding[]}
@@ -123,18 +128,29 @@ export function validateConfigShape(config, file = ".tot/config.json") {
123
128
  }
124
129
  if (typeof m.workspace !== "string" || !m.workspace) {
125
130
  out.push(mk(ERROR, "mapping-workspace", at, "`workspace` must be a non-empty string"));
126
- } else {
127
- seen.add(m.workspace.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
128
- seen.add(m.workspace);
129
131
  }
130
- // THE regression guard: reconcile's mapToArtifact dereferences m.repo and only
131
- // knows "file" vs tree — a missing repo or kind:"dir" makes it throw at runtime.
132
+ // THE regression guard: reconcile reads the tenant-relative target out of
133
+ // m.repo and only knows "file" vs tree — a missing/unparseable repo or
134
+ // kind:"dir" is refused there, so refuse it here first.
132
135
  if (typeof m.repo !== "string" || !m.repo) {
133
136
  out.push(
134
137
  mk(ERROR, "mapping-repo", at,
135
- `mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (reconcile mapToArtifact throws on undefined.repo)`,
138
+ `mapping for "${m.workspace ?? "?"}" is missing a string \`repo\` (the reconcile refuses it)`,
136
139
  `add "repo": "tenants/${config.tenant ?? "<id>"}/${(m.workspace ?? "").replace(/\/+$/, "")}${m.kind === "file" ? "" : "/"}"`),
137
140
  );
141
+ } else {
142
+ const target = parseMappingTarget(m.repo);
143
+ if (!target) {
144
+ out.push(
145
+ mk(ERROR, "mapping-repo", at,
146
+ `mapping for "${m.workspace ?? "?"}" targets "${m.repo}" — \`repo\` must be a clean path under tenants/<id>/ (the reconcile refuses it)`,
147
+ `e.g. "repo": "tenants/${config.tenant ?? "<id>"}/content/"`),
148
+ );
149
+ } else {
150
+ // Coverage is about WHAT is mapped (the target), not where it lives.
151
+ seen.add(target.rel.replace(/\/+$/, "") + (m.kind === "tree" ? "/" : ""));
152
+ seen.add(target.rel);
153
+ }
138
154
  }
139
155
  if (!KNOWN_KINDS.has(m.kind)) {
140
156
  out.push(
@@ -998,16 +1014,9 @@ export function validateTenant(tenantDir, opts = {}) {
998
1014
  /** @type {Finding[]} */
999
1015
  const findings = [];
1000
1016
  const rel = (p) => relative(tenantDir, p) || p;
1001
- const contentDir = join(tenantDir, "content");
1002
- const publicDir = join(tenantDir, "public");
1003
- const pagesDir = join(contentDir, "pages-html");
1004
-
1005
- // 0. basic layout
1006
- if (!existsSync(contentDir)) {
1007
- findings.push(mk(ERROR, "layout", "content/", "no content/ directory"));
1008
- }
1009
1017
 
1010
- // 1. .tot/config.json — shape (flow-back readiness)
1018
+ // 1. .tot/config.json — shape (flow-back readiness). Read first: its mappings
1019
+ // say where theme.json / content/ / public/ live in this tree.
1011
1020
  const configPath = join(tenantDir, ".tot", "config.json");
1012
1021
  let config = null;
1013
1022
  if (existsSync(configPath)) {
@@ -1018,7 +1027,18 @@ export function validateTenant(tenantDir, opts = {}) {
1018
1027
  config = value;
1019
1028
  findings.push(...validateConfigShape(value, ".tot/config.json"));
1020
1029
  }
1021
- } else {
1030
+ }
1031
+ const located = (tenantRel) => join(tenantDir, workspacePathForTenantRelative(config, tenantRel));
1032
+ const contentDir = located("content");
1033
+ const publicDir = located("public");
1034
+ const pagesDir = join(contentDir, "pages-html");
1035
+
1036
+ // 0. basic layout
1037
+ if (!existsSync(contentDir)) {
1038
+ findings.push(mk(ERROR, "layout", rel(contentDir) + "/", `no ${rel(contentDir)}/ directory`));
1039
+ }
1040
+
1041
+ if (!existsSync(configPath)) {
1022
1042
  findings.push(
1023
1043
  mk(WARN, "config-absent", ".tot/config.json",
1024
1044
  "no .tot/config.json — tenant can serve but is NOT flow-back-ready (reconcile has nothing to map)",
@@ -1031,7 +1051,28 @@ export function validateTenant(tenantDir, opts = {}) {
1031
1051
  );
1032
1052
  }
1033
1053
  const scope = opts.scope || config?.scope;
1034
- const activeTenantId = opts.tenantId || scope || config?.tenant;
1054
+ // The canonical tenant id: the one the asset rule binds to and the one the
1055
+ // mappings' targets resolve under — never a directory name.
1056
+ const activeTenantId = opts.tenantId || config?.tenant || scope;
1057
+ if (config && activeTenantId) {
1058
+ for (const f of foreignTenantSegments(config, activeTenantId)) {
1059
+ findings.push(
1060
+ mk(WARN, "mapping-tenant-segment", ".tot/config.json",
1061
+ `mapping for "${f.workspace}" targets "${f.repo}", naming tenant "${f.segment}" — this store is "${activeTenantId}". ` +
1062
+ `It still resolves under tenants/${activeTenantId}/ (the segment is not read), and its assets are served at /tenants/${activeTenantId}/...`,
1063
+ `write "repo": "tenants/${activeTenantId}/${parseMappingTarget(f.repo)?.rel ?? ""}" so the config agrees with itself ` +
1064
+ `(the agency kit's tools/repair-tenant-config.mjs rewrites the whole config)`),
1065
+ );
1066
+ }
1067
+ }
1068
+ // Publication refuses a config whose `scope` is not the store's id.
1069
+ if (config && typeof config.tenant === "string" && config.scope !== config.tenant) {
1070
+ findings.push(
1071
+ mk(WARN, "config-scope-mismatch", ".tot/config.json",
1072
+ `"scope" is ${JSON.stringify(config.scope ?? null)} but "tenant" is "${config.tenant}" — publication refuses a config whose scope is not the store's id`,
1073
+ `set "scope" to "${config.tenant}" (the agency kit's tools/repair-tenant-config.mjs rewrites the whole config)`),
1074
+ );
1075
+ }
1035
1076
  // platform-route awareness: which framework routes the host platform owns for this tenant
1036
1077
  const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
1037
1078
  const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
@@ -1041,18 +1082,18 @@ export function validateTenant(tenantDir, opts = {}) {
1041
1082
  }
1042
1083
 
1043
1084
  // 2. theme.json — parse (a malformed one is a WHOLE-APP BUILD FAILURE)
1044
- const themePath = join(tenantDir, "theme.json");
1085
+ const themePath = located("theme.json");
1045
1086
  if (existsSync(themePath)) {
1046
1087
  const { error } = readJsonSafe(themePath);
1047
1088
  if (error) {
1048
1089
  findings.push(
1049
- mk(ERROR, "theme-parse", "theme.json",
1090
+ mk(ERROR, "theme-parse", rel(themePath),
1050
1091
  `invalid JSON: ${error}`, "theme.json is loaded by an eager Vite glob — a parse error fails the entire storefront build"),
1051
1092
  );
1052
1093
  }
1053
1094
  }
1054
1095
 
1055
- const capabilitiesPath = join(tenantDir, "capabilities.json");
1096
+ const capabilitiesPath = located("capabilities.json");
1056
1097
  if (existsSync(capabilitiesPath)) {
1057
1098
  const { value, error } = readJsonSafe(capabilitiesPath);
1058
1099
  if (error) {
@@ -1062,7 +1103,7 @@ export function validateTenant(tenantDir, opts = {}) {
1062
1103
  }
1063
1104
  }
1064
1105
 
1065
- const scriptsPath = join(tenantDir, "scripts.json");
1106
+ const scriptsPath = located("scripts.json");
1066
1107
  if (existsSync(scriptsPath)) {
1067
1108
  const { value, error } = readJsonSafe(scriptsPath);
1068
1109
  if (error) {
@@ -1072,7 +1113,7 @@ export function validateTenant(tenantDir, opts = {}) {
1072
1113
  }
1073
1114
  }
1074
1115
 
1075
- const embedsPath = join(tenantDir, "embeds.json");
1116
+ const embedsPath = located("embeds.json");
1076
1117
  if (existsSync(embedsPath)) {
1077
1118
  const { value, error } = readJsonSafe(embedsPath);
1078
1119
  if (error) {
@@ -1177,9 +1218,7 @@ export function validateTenant(tenantDir, opts = {}) {
1177
1218
  // content/ plus the root config files.
1178
1219
  const conflictScanFiles = [
1179
1220
  ...walk(contentDir, hasScanExt),
1180
- ...["theme.json", "capabilities.json", "scripts.json", join(".tot", "config.json")]
1181
- .map((f) => join(tenantDir, f))
1182
- .filter((p) => existsSync(p)),
1221
+ ...[themePath, capabilitiesPath, scriptsPath, configPath].filter((p) => existsSync(p)),
1183
1222
  ];
1184
1223
  for (const p of conflictScanFiles) {
1185
1224
  const lines = detectConflictMarkers(readFileSync(p, "utf8"));
@@ -1200,9 +1239,8 @@ export function validateTenant(tenantDir, opts = {}) {
1200
1239
  // compare against, since the opt-in may be the platform build-time default.
1201
1240
  if (existsSync(contentDir)) {
1202
1241
  let declaredEmbeds = null;
1203
- const embedsFile = join(tenantDir, "embeds.json");
1204
- if (existsSync(embedsFile)) {
1205
- const { value } = readJsonSafe(embedsFile);
1242
+ if (existsSync(embedsPath)) {
1243
+ const { value } = readJsonSafe(embedsPath);
1206
1244
  if (Array.isArray(value)) declaredEmbeds = value.filter((s) => typeof s === "string");
1207
1245
  }
1208
1246
  for (const f of lintTenantEmbeds({ dir: contentDir, declared: declaredEmbeds }).findings) {
@@ -62,7 +62,7 @@ import {
62
62
  import { dirname, resolve, join } from "node:path";
63
63
  import { fileURLToPath } from "node:url";
64
64
  import { resolveDevSourceRoot } from "./dev/tenant-source-root.mjs";
65
- import { tenantDirRelative, tenantDirSegments } from "./lib/tenant-dirs.mjs";
65
+ import { tenantDirSegments } from "./lib/tenant-dirs.mjs";
66
66
  import { firstFreePort, isPortFree } from "./dev/port-check.mjs";
67
67
  import { busyPortMessage, classifyBusyPort, lockPort, unlockPort } from "./dev/port-lock.mjs";
68
68
  import { resync, startWatch } from "./dev/checkout-watch.mjs";
@@ -276,12 +276,12 @@ if (workspacePath) {
276
276
  console.log(` checkout: ${wsRoot}`);
277
277
 
278
278
  /**
279
- * Graft one checkout path (`wsRel`, checkout-relative) into the in-tree
280
- * `tenants/<tenant>/` layout the app globs, with the same move-aside/restore +
281
- * watch-and-resync machinery every mapping gets. Returns true if grafted.
279
+ * Graft one checkout path (`wsRel`, checkout-relative) to `repoRel` in the
280
+ * in-tree `tenants/<tenant>/` layout the app globs, with the same
281
+ * move-aside/restore + watch-and-resync machinery every mapping gets. Returns
282
+ * true if grafted.
282
283
  */
283
- function graftOne(wsRel, isDir) {
284
- const repoRel = join(tenantDirRelative(tenant), wsRel);
284
+ function graftOne(wsRel, repoRel, isDir) {
285
285
 
286
286
  const source = resolve(wsRoot, wsRel); // real files in the checkout
287
287
  const target = resolve(repoRoot, repoRel); // where the app globs them
@@ -320,19 +320,19 @@ if (workspacePath) {
320
320
  return true;
321
321
  }
322
322
 
323
- // `workspace` is the checkout-relative prefix (e.g. "content/", "theme.json").
324
- // COLOCATION: a tenant IS the repo-root dir `tenants/<id>/`, and the checkout
325
- // (a `git subtree split --prefix=tenants/<id>` of that dir) has the SAME layout
326
- // at its root — so the graft is an identity map: content/ -> tenants/<id>/content/,
327
- // public/ -> tenants/<id>/public/, theme.json -> tenants/<id>/theme.json. We map
328
- // structurally (ignoring any legacy `repo` field in the checkout's .tot/config.json).
329
- // standaloneGraftPlan ALSO appends `.tot/config.json` unconditionally — see its
330
- // own doc comment. Its success does NOT count toward "did we graft a usable
331
- // site" below: a checkout with config but no content/public/theme is still
332
- // nothing to serve.
323
+ // Each mapping's checkout files land where its `repo` says, under THIS tenant's
324
+ // directory (see scripts/dev/standalone-graft-plan.mjs) — resolved exactly as
325
+ // the hosted reconcile resolves them. standaloneGraftPlan ALSO appends
326
+ // `.tot/config.json` unconditionally — see its own doc comment. Its success does
327
+ // NOT count toward "did we graft a usable site" below: a checkout with config but
328
+ // no content/public/theme is still nothing to serve.
333
329
  let mappedCount = 0;
334
- for (const { workspace, isDir } of standaloneGraftPlan(mappings)) {
335
- const grafted = graftOne(workspace, isDir);
330
+ for (const { workspace, target, isDir } of standaloneGraftPlan(tenant, mappings)) {
331
+ if (!target) {
332
+ console.warn(` ⚠ mapping for "${workspace}" has no valid "repo" under tenants/<id>/ — skipping (run tot validate)`);
333
+ continue;
334
+ }
335
+ const grafted = graftOne(workspace, target, isDir);
336
336
  if (workspace === ".tot/config.json") {
337
337
  if (!grafted) console.warn(` ⚠ checkout has no .tot/config.json — compliance/capabilities/siteType resolve undeclared.`);
338
338
  continue;