@tokenoftrust/cli 2.4.2 → 2.4.3

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.4.2",
3
+ "version": "2.4.3",
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,223 @@
1
+ /**
2
+ * A tenant's publication topology as a developer's workspace sees it: one ROOT repository serving
3
+ * `/`, plus any MOUNTED spaces — each its own repository serving one mount (`/blog`, `/docs`).
4
+ *
5
+ * The platform declares the topology; the workspace only records what it was told, in
6
+ * `.tot/publication-spaces.json` (written by `tot clone`, never committed — the root repo's git
7
+ * excludes it, and the `spaces/` folder each mounted space is cloned into). Every command that needs
8
+ * to know which repository owns a URL or a file reads it through this module, so `tot validate`,
9
+ * `tot dev`, `tot clone` and the change router answer the same way.
10
+ *
11
+ * Mount matching mirrors packages/public-runtime/src/publication-space.ts
12
+ * (`publicationMountMatchesPath`): a mount owns itself and everything below it, segment-wise and
13
+ * case-sensitively; the longest matching mount wins and `/` owns what nothing else claims.
14
+ * apps/storefront/src/config/publicationSpaceOwnership.test.ts pins the two together.
15
+ *
16
+ * Dependency-free: the published CLI ships it as-is.
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+
21
+ /** Where `tot clone` puts each mounted space, relative to the root checkout: `spaces/<spaceId>/`. */
22
+ export const SPACES_DIR = "spaces";
23
+ /** The workspace's record of the declared topology, relative to the root checkout. */
24
+ export const TOPOLOGY_FILE = ".tot/publication-spaces.json";
25
+ export const TOPOLOGY_VERSION = 1;
26
+
27
+ const SPACE_ID = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
28
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
29
+
30
+ /**
31
+ * @typedef {{ forge: string, repo: string, defaultBranch: string }} SpaceSource
32
+ * @typedef {{ id: string, mount: string, visibility: "public"|"internal", source: SpaceSource,
33
+ * aliases?: string[], collections?: Record<string, string> }} TopologySpace
34
+ * @typedef {{ version: 1, tenant: string, spaces: TopologySpace[] }} Topology
35
+ */
36
+
37
+ /** A mount in canonical form: leading `/`, no trailing `/` except the root itself. Null when not a mount. */
38
+ export function normalizeMount(value) {
39
+ if (typeof value !== "string" || !value.startsWith("/") || /[?#\\]/.test(value)) return null;
40
+ const trimmed = value.replace(/\/+$/, "");
41
+ if (!trimmed) return "/";
42
+ if (trimmed.split("/").slice(1).some((seg) => !seg || seg === "." || seg === "..")) return null;
43
+ return trimmed;
44
+ }
45
+
46
+ /** A request path reduced to what routing reads: no origin, query or fragment; NFC; single slashes. */
47
+ export function normalizeRequestPath(value) {
48
+ let path = String(value ?? "").trim();
49
+ try {
50
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) path = new URL(path).pathname;
51
+ } catch {
52
+ return null;
53
+ }
54
+ path = path.replace(/[?#].*$/, "");
55
+ if (!path.startsWith("/")) path = `/${path}`;
56
+ try {
57
+ path = decodeURIComponent(path);
58
+ } catch {
59
+ return null;
60
+ }
61
+ path = path.normalize("NFC").replace(/\/{2,}/g, "/");
62
+ return path;
63
+ }
64
+
65
+ /** Does `mount` own `path` (both normalized)? A mount owns itself and every path below it. */
66
+ export function mountMatchesPath(mount, path) {
67
+ if (mount === "/") return true;
68
+ return path === mount || path === `${mount}/` || path.startsWith(`${mount}/`);
69
+ }
70
+
71
+ /**
72
+ * Parse a recorded topology. Throws with the reason on anything malformed — a half-read topology
73
+ * would route a change to the wrong repository.
74
+ * @param {unknown} input
75
+ * @returns {Topology}
76
+ */
77
+ export function parseTopology(input) {
78
+ const raw = /** @type {any} */ (input);
79
+ if (!isObject(raw) || raw.version !== TOPOLOGY_VERSION || typeof raw.tenant !== "string" || !raw.tenant ||
80
+ !Array.isArray(raw.spaces)) {
81
+ throw new Error(`${TOPOLOGY_FILE} is not a version-${TOPOLOGY_VERSION} topology`);
82
+ }
83
+ const ids = new Set();
84
+ const mounts = new Set();
85
+ const spaces = raw.spaces.map((space, index) => {
86
+ const at = `${TOPOLOGY_FILE} spaces[${index}]`;
87
+ if (!isObject(space) || typeof space.id !== "string" || !SPACE_ID.test(space.id)) throw new Error(`${at}: invalid id`);
88
+ const mount = normalizeMount(space.mount);
89
+ if (!mount) throw new Error(`${at}: invalid mount`);
90
+ if (space.visibility !== "public" && space.visibility !== "internal") throw new Error(`${at}: invalid visibility`);
91
+ const source = space.source;
92
+ if (!isObject(source) || typeof source.forge !== "string" || typeof source.repo !== "string" || !source.repo ||
93
+ typeof source.defaultBranch !== "string" || !source.defaultBranch) throw new Error(`${at}: invalid source`);
94
+ if (ids.has(space.id)) throw new Error(`${at}: duplicate id ${space.id}`);
95
+ if (mounts.has(mount)) throw new Error(`${at}: duplicate mount ${mount}`);
96
+ ids.add(space.id);
97
+ mounts.add(mount);
98
+ const aliases = Array.isArray(space.aliases) ? space.aliases.map(normalizeMount).filter(Boolean) : [];
99
+ return {
100
+ id: space.id,
101
+ mount,
102
+ visibility: space.visibility,
103
+ source: { forge: source.forge, repo: source.repo, defaultBranch: source.defaultBranch },
104
+ ...(aliases.length ? { aliases } : {}),
105
+ ...(isObject(space.collections) ? { collections: { ...space.collections } } : {}),
106
+ };
107
+ });
108
+ if (spaces.filter((space) => space.mount === "/").length !== 1) {
109
+ throw new Error(`${TOPOLOGY_FILE}: exactly one space must own "/"`);
110
+ }
111
+ return { version: TOPOLOGY_VERSION, tenant: raw.tenant, spaces };
112
+ }
113
+
114
+ /**
115
+ * Build a topology from the platform's declared-space list (`GET /api/publication-spaces`, whose
116
+ * rows carry `id`, `visibility`, `canonicalMount`, `source` and `routes`).
117
+ * @param {string} tenant
118
+ * @param {unknown} rows
119
+ * @returns {Topology}
120
+ */
121
+ export function topologyFromDeclaredSpaces(tenant, rows) {
122
+ if (!Array.isArray(rows)) throw new Error("the platform returned no publication-space list");
123
+ return parseTopology({
124
+ version: TOPOLOGY_VERSION,
125
+ tenant,
126
+ spaces: rows.map((row) => ({
127
+ id: row?.id,
128
+ mount: row?.routes?.canonicalMount ?? row?.canonicalMount,
129
+ visibility: row?.visibility,
130
+ source: row?.source,
131
+ aliases: Array.isArray(row?.routes?.aliases) ? row.routes.aliases.map((alias) => alias?.from) : undefined,
132
+ collections: row?.routes?.collections,
133
+ })),
134
+ });
135
+ }
136
+
137
+ /** The root space (owns `/`; `parseTopology` guarantees exactly one). @param {Topology} topology */
138
+ export function rootSpace(topology) {
139
+ return /** @type {TopologySpace} */ (topology.spaces.find((space) => space.mount === "/"));
140
+ }
141
+
142
+ /** The mounted spaces, longest mount first. @param {Topology} topology */
143
+ export function mountedSpaces(topology) {
144
+ return topology.spaces.filter((space) => space.mount !== "/").sort((a, b) => b.mount.length - a.mount.length);
145
+ }
146
+
147
+ /**
148
+ * Which space owns a URL path: the longest canonical mount (or alias) that owns it, else the root.
149
+ * Returns null for a path that cannot be read as a URL path.
150
+ * @param {Topology} topology
151
+ * @param {string} urlPath
152
+ * @returns {{ space: TopologySpace, via: "mount"|"alias"|"root" } | null}
153
+ */
154
+ export function owningSpace(topology, urlPath) {
155
+ const path = normalizeRequestPath(urlPath);
156
+ if (path === null) return null;
157
+ /** @type {{ space: TopologySpace, via: "mount"|"alias", mount: string } | null} */
158
+ let best = null;
159
+ for (const space of topology.spaces) {
160
+ if (space.mount === "/") continue;
161
+ /** @type {Array<[string, "mount"|"alias"]>} */
162
+ const claims = [[space.mount, "mount"], ...(space.aliases ?? []).map((alias) => /** @type {[string, "alias"]} */ ([alias, "alias"]))];
163
+ for (const [mount, via] of claims) {
164
+ if (mountMatchesPath(mount, path) && (!best || mount.length > best.mount.length)) best = { space, via, mount };
165
+ }
166
+ }
167
+ if (best) return { space: best.space, via: best.via };
168
+ return { space: rootSpace(topology), via: "root" };
169
+ }
170
+
171
+ /** A mounted space's working tree, relative to the root checkout. @param {TopologySpace} space */
172
+ export function spaceDir(space) {
173
+ return space.mount === "/" ? "" : `${SPACES_DIR}/${space.id}`;
174
+ }
175
+
176
+ /**
177
+ * Which space a workspace file belongs to, from its path relative to the root checkout:
178
+ * `spaces/<id>/...` is that space's, everything else is the root's. A file under `spaces/` naming
179
+ * no declared space belongs to none (null) — it must not be routed anywhere.
180
+ * @param {Topology} topology
181
+ * @param {string} relPath
182
+ * @returns {{ space: TopologySpace, path: string } | null}
183
+ */
184
+ export function spaceForWorkspaceFile(topology, relPath) {
185
+ const rel = String(relPath).replace(/\\/g, "/").replace(/^\.\//, "");
186
+ if (rel === SPACES_DIR || rel.startsWith(`${SPACES_DIR}/`)) {
187
+ const [, id, ...rest] = rel.split("/");
188
+ const space = topology.spaces.find((candidate) => candidate.mount !== "/" && candidate.id === id);
189
+ return space ? { space, path: rest.join("/") } : null;
190
+ }
191
+ return { space: rootSpace(topology), path: rel };
192
+ }
193
+
194
+ /** Read a workspace's recorded topology, or null when the workspace records none. Throws when malformed. */
195
+ export function readWorkspaceTopology(workspace) {
196
+ const file = join(workspace, TOPOLOGY_FILE);
197
+ if (!existsSync(file)) return null;
198
+ return parseTopology(JSON.parse(readFileSync(file, "utf8")));
199
+ }
200
+
201
+ /**
202
+ * Record a topology in a root checkout, and keep it — and the cloned spaces — out of the root
203
+ * repository: both are listed in the checkout's own `.git/info/exclude`, never in a committed file.
204
+ */
205
+ export function writeWorkspaceTopology(workspace, topology) {
206
+ const file = join(workspace, TOPOLOGY_FILE);
207
+ mkdirSync(dirname(file), { recursive: true });
208
+ writeFileSync(file, `${JSON.stringify(parseTopology(topology), null, 2)}\n`);
209
+ excludeFromRootRepo(workspace, [`/${TOPOLOGY_FILE}`, `/${SPACES_DIR}/`]);
210
+ }
211
+
212
+ /** Append patterns to `<workspace>/.git/info/exclude`, once each. No-op outside a git checkout. */
213
+ export function excludeFromRootRepo(workspace, patterns) {
214
+ const gitDir = join(workspace, ".git");
215
+ if (!existsSync(gitDir)) return;
216
+ const file = join(gitDir, "info", "exclude");
217
+ mkdirSync(dirname(file), { recursive: true });
218
+ const current = existsSync(file) ? readFileSync(file, "utf8") : "";
219
+ const lines = new Set(current.split("\n").map((line) => line.trim()));
220
+ const missing = patterns.filter((pattern) => !lines.has(pattern));
221
+ if (!missing.length) return;
222
+ appendFileSync(file, `${current && !current.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`);
223
+ }