@tokenoftrust/cli 2.2.11 → 2.2.13

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.11",
3
+ "version": "2.2.13",
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",
@@ -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(cacheRoot, keepVersion) {
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
- rmSync(join(cacheRoot, name), { recursive: true, force: true });
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
+ }
package/src/validate.mjs CHANGED
@@ -63,6 +63,71 @@ const hasScanExt = (p) => CONFLICT_SCAN_EXT.has((p.match(/\.[^./\\]+$/) || [""])
63
63
  const KNOWN_KINDS = new Set(["file", "tree"]);
64
64
  const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
65
65
 
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
+
66
131
  /**
67
132
  * Validate a parsed `.tot/config.json` against the shape the reconcile pipeline
68
133
  * (`mapToArtifact`) requires. Pure — no I/O.
@@ -76,6 +141,7 @@ export function validateConfigShape(config, file = ".tot/config.json") {
76
141
  if (typeof config.tenant !== "string" || !config.tenant) {
77
142
  out.push(mk(ERROR, "config-tenant", file, "`tenant` must be a non-empty string"));
78
143
  }
144
+ out.push(...validateDeclaredConfig(config, file));
79
145
  if (!Array.isArray(config.mappings)) {
80
146
  out.push(mk(ERROR, "config-mappings", file, "`mappings` must be an array", "seed shape: [{workspace,repo,kind}]"));
81
147
  return out;
@@ -25,7 +25,7 @@
25
25
  },
26
26
  {
27
27
  "label": "About",
28
- "href": "/about"
28
+ "href": "/pages/about-us"
29
29
  }
30
30
  ],
31
31
  "footer": {
@@ -57,15 +57,15 @@
57
57
  "links": [
58
58
  {
59
59
  "label": "About",
60
- "href": "/about"
60
+ "href": "/pages/about-us"
61
61
  },
62
62
  {
63
63
  "label": "Shipping & Returns",
64
- "href": "/shipping-returns"
64
+ "href": "/pages/shipping-returns"
65
65
  },
66
66
  {
67
67
  "label": "Privacy",
68
- "href": "/privacy"
68
+ "href": "/pages/privacy-policy"
69
69
  }
70
70
  ]
71
71
  }