@tokenoftrust/cli 2.4.1 → 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.1",
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",
@@ -28,11 +28,12 @@
28
28
  * needed. `--watch` just raises the budget (longer waitMs, more attempts) so an
29
29
  * attached dev can stay and watch reconcile+compliance resolve live.
30
30
  *
31
- * Watching through to accept (E1b): `--watch` also sets untilShipped, so once
32
- * reconcile resolves cleanly the loop keeps going (preview_status reuses the
33
- * SAME waitMs budget server-side to long-poll for a ship decision too — one
34
- * poll axis, not two) until change_accept ships it or the attempts budget
35
- * runs out. A failed reconcile never waits for ship — it can't have shipped.
31
+ * Watching through to live (E1b): `--watch` also sets untilShipped, so once
32
+ * reconcile resolves cleanly the loop keeps going until the pipeline verdict
33
+ * (`preview_status.verdict`) shows this commit live — on Cloudflare once the
34
+ * pointer flip is proven, on CloudFront once the CI publish is confirmed — or the
35
+ * attempts budget runs out. A failed reconcile never waits for ship — it can't
36
+ * have shipped.
36
37
  * Detached devs get the E1 email instead; this is the attached half.
37
38
  *
38
39
  * Dependency-free (global fetch + `git`).
@@ -1798,7 +1799,7 @@ export async function run(argv, ctx, {
1798
1799
  let status;
1799
1800
  operation = "preview_status";
1800
1801
  if (args.noWait) {
1801
- status = normalizePreviewStatus(await scoped.callTool("preview_status", { commit: statusSha }));
1802
+ status = normalizePreviewStatus(await scoped.callTool("preview_status", { commit: statusSha }), statusSha);
1802
1803
  } else {
1803
1804
  // One in-place status line (TTY: a spinner with an elapsed-seconds counter;
1804
1805
  // non-TTY: a ~10s heartbeat) instead of a newline per poll — the wait reads
@@ -1880,10 +1881,10 @@ export async function run(argv, ctx, {
1880
1881
  /**
1881
1882
  * Normalize a `preview_status` tool response to the contract shape the CLI reports
1882
1883
  * on: { status, reconcile:{ok,errors}, compliance:{verdict,detail}, previewUrl,
1883
- * shipped, delivery, dispatched }. A live tool always returns status
1884
+ * shipped, delivery, dispatched, verdict }. A live tool always returns status
1884
1885
  * pending|reconciled|failed; anything else (an older MCP without the tool's flat
1885
1886
  * fields) normalizes to "unknown" so the CLI degrades visibly instead of pretending
1886
- * it reconciled. `shipped` (E1b) is null until change_accept ships this exact commit.
1887
+ * it reconciled. `shipped` (E1b) is derived from the verdict: null until this commit is live.
1887
1888
  *
1888
1889
  * `delivery` is the MCP's reconcile-observability triad for the Gitea webhook that
1889
1890
  * fired on this commit ({ target, actual, evidence, drift? }) — or null when NO
@@ -1893,7 +1894,11 @@ export async function run(argv, ctx, {
1893
1894
  * read under a different tenant scope than it was pushed to), which is a permanent
1894
1895
  * dead-end the CLI must not report as "still reconciling". Pure — unit-tested.
1895
1896
  */
1896
- export function normalizePreviewStatus(r) {
1897
+ /**
1898
+ * @param {any} r the `preview_status` response
1899
+ * @param {string|null} [commit] the sha the status was asked for (what `shipped` is decided against)
1900
+ */
1901
+ export function normalizePreviewStatus(r, commit = null) {
1897
1902
  const status = r?.status;
1898
1903
  const known = status === "pending" || status === "reconciled" || status === "failed";
1899
1904
  const delivery = r?.delivery ?? null;
@@ -1902,7 +1907,7 @@ export function normalizePreviewStatus(r) {
1902
1907
  reconcile: r?.reconcile ?? null,
1903
1908
  compliance: r?.compliance ?? null,
1904
1909
  previewUrl: r?.previewUrl ?? null,
1905
- shipped: r?.shipped ?? null,
1910
+ shipped: shippedFromVerdict(r?.verdict ?? null, commit),
1906
1911
  delivery,
1907
1912
  dispatched: delivery != null,
1908
1913
  // The ONE pipeline-status read every surface (console, `preview_status`, `tot
@@ -1913,6 +1918,26 @@ export function normalizePreviewStatus(r) {
1913
1918
  };
1914
1919
  }
1915
1920
 
1921
+ /**
1922
+ * Whether the pipeline verdict shows `commit` live, and where: the live release IS this commit, or
1923
+ * the live release is the next release this commit's change was merged into (its membership names
1924
+ * the commit as a change head). Per provider, the verdict's live release is recorded only once
1925
+ * delivery is proven — the Cloudflare pointer flip read back, or the CloudFront CI publish
1926
+ * confirmed. Null when not live (or no verdict). Pure — unit-tested.
1927
+ * @param {any} verdict @param {string|null} commit
1928
+ * @returns {{ sha: string, receiptId: string|null, provider: string|null, provedAt: string|null }|null}
1929
+ */
1930
+ export function shippedFromVerdict(verdict, commit) {
1931
+ if (!commit || verdict?.kind !== "idle" || !verdict.live?.sha) return null;
1932
+ const live = verdict.live;
1933
+ const merged =
1934
+ verdict.preview?.sha === live.sha &&
1935
+ Array.isArray(verdict.preview?.membership) &&
1936
+ verdict.preview.membership.some((m) => m?.headSha === commit);
1937
+ if (live.sha !== commit && !merged) return null;
1938
+ return { sha: live.sha, receiptId: live.receiptId ?? null, provider: live.provider ?? null, provedAt: live.provedAt ?? null };
1939
+ }
1940
+
1916
1941
  /** A submit succeeds only when reconcile has neither failed nor failed to start. */
1917
1942
  export function previewSubmitSucceeded(status) {
1918
1943
  const knownSuccessState = status?.status === "pending" || status?.status === "reconciled";
@@ -1971,7 +1996,7 @@ export async function pollPreviewStatus(
1971
1996
  for (let i = 0; i < attempts; i++) {
1972
1997
  const startedAt = now();
1973
1998
  const args = waitMs ? { commit, waitMs } : { commit };
1974
- last = normalizePreviewStatus(await scoped.callTool("preview_status", args));
1999
+ last = normalizePreviewStatus(await scoped.callTool("preview_status", args), commit);
1975
2000
  if (last.dispatched) everDispatched = true;
1976
2001
  last.everDispatched = everDispatched;
1977
2002
  if (onTick) onTick(last, i);
@@ -2194,6 +2219,25 @@ export function formatVerdictBlock(verdict, supportBundleText = null) {
2194
2219
  * --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
2195
2220
  * feed the honest never-dispatched block.
2196
2221
  */
2222
+ /**
2223
+ * The ship line after a watched submit: where this commit is live (per provider) once the verdict
2224
+ * shows it, or that a reviewer still has to accept it. Null when reconcile has not settled. Pure.
2225
+ * @param {{ status?: string, shipped?: { sha: string, receiptId: string|null, provider: string|null, provedAt: string|null }|null }} s
2226
+ */
2227
+ export function formatShipLine(s) {
2228
+ if (s?.shipped) {
2229
+ const where =
2230
+ s.shipped.provider === "s3-overwrite" ? "CloudFront" : s.shipped.provider === "worker-r2-pointer" ? "Cloudflare" : "your live site";
2231
+ return (
2232
+ `\n ✓ shipped — live at ${s.shipped.sha.slice(0, 9)} on ${where}` +
2233
+ (s.shipped.receiptId ? ` (receipt ${s.shipped.receiptId})` : "") +
2234
+ (s.shipped.provedAt ? `, proven ${s.shipped.provedAt}` : "")
2235
+ );
2236
+ }
2237
+ if (s?.status === "reconciled") return `\n ~ not yet shipped — a reviewer still needs to run change_accept.`;
2238
+ return null;
2239
+ }
2240
+
2197
2241
  /**
2198
2242
  * @param {any} s @param {string} tenant
2199
2243
  * @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
@@ -2249,11 +2293,8 @@ function reportStatus(s, tenant, { open = true, quiet = false, commit = null, re
2249
2293
  console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
2250
2294
  for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
2251
2295
  }
2252
- if (s.shipped) {
2253
- console.log(`\n ✓ shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
2254
- } else if (s.status === "reconciled") {
2255
- console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
2256
- }
2296
+ const shipLine = formatShipLine(s);
2297
+ if (shipLine) console.log(shipLine);
2257
2298
  for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
2258
2299
  }
2259
2300
  if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
@@ -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
+ }