@tokenoftrust/cli 2.4.3 → 2.4.4

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.3",
3
+ "version": "2.4.4",
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",
@@ -11,6 +11,18 @@
11
11
  * tot clone <tenant> <dir> clone into <dir>
12
12
  * tot clone <tenant> --remote-only just mint + print the remote, don't materialize
13
13
  * tot clone agency-kit [dir] READ-ONLY clone of the Agency Kit (ADR 0037)
14
+ * tot clone --spaces (inside a checkout) record the store's publication spaces and
15
+ * clone any mounted space not yet under spaces/
16
+ *
17
+ * MOUNTED PUBLICATION SPACES. A store whose site is split across repositories (a root plus
18
+ * mounted spaces such as `/blog`) is cloned whole: after the root, `tot clone` reads the
19
+ * platform's declared split (`GET /api/publication-spaces`, over the developer's own viewer
20
+ * session), records it in `.tot/publication-spaces.json`, and clones each mounted space it can
21
+ * get a credential for into `spaces/<spaceId>/`. Space credentials come only from the MCP
22
+ * `publication_checkout` tool; like the root's, the token rides a one-shot header, is never
23
+ * written to `.git/config`, and is never printed. Each space checkout records its kind so
24
+ * `tot git-credential` re-mints its credential through `publication_checkout` on every fetch and
25
+ * push, never as the root store's.
14
26
  *
15
27
  * AGENCY KIT. `agency-kit` is an alias, not a tenant: it mints through
16
28
  * `repo_read_credential` (see READ_ONLY_ALIASES / kitReadCredentialInput) and skips
@@ -53,6 +65,8 @@
53
65
  * Dependency-free (global fetch + `git` via child_process).
54
66
  */
55
67
  import { execFile, execFileSync } from "node:child_process";
68
+ import { existsSync } from "node:fs";
69
+ import { join } from "node:path";
56
70
  import { promisify } from "node:util";
57
71
  import { createMcpClient } from "../mcp.mjs";
58
72
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
@@ -63,7 +77,12 @@ import { writeNvmrc } from "../sample.mjs";
63
77
  import { emitObstacle } from "../obstacle.mjs";
64
78
  import {
65
79
  splitAuthedRemote, basicAuthExtraHeader, installForgeCredentialHelper, recordReadOnlyKitCheckout,
80
+ recordPublicationSpaceCheckout,
66
81
  } from "../git-credential.mjs";
82
+ import { resolveViewerTransport } from "../viewer-session.mjs";
83
+ import {
84
+ mountedSpaces, spaceDir, topologyFromDeclaredSpaces, writeWorkspaceTopology, TOPOLOGY_FILE,
85
+ } from "../publication-spaces.mjs";
67
86
  import { recordDiagnostic } from "../diagnostics.mjs";
68
87
 
69
88
  const execFileP = promisify(execFile);
@@ -71,7 +90,7 @@ const execFileP = promisify(execFile);
71
90
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
72
91
 
73
92
  function parseArgs(argv) {
74
- /** @type {{ tenant: string|null, dir: string|null, tag: string, pr: string|null, remoteOnly: boolean, mcp: string|null, printRemote: boolean, help: boolean }} */
93
+ /** @type {{ tenant: string|null, dir: string|null, tag: string, pr: string|null, remoteOnly: boolean, mcp: string|null, url: string|null, printRemote: boolean, spaces: boolean, help: boolean }} */
75
94
  const a = {
76
95
  tenant: null,
77
96
  dir: null,
@@ -79,7 +98,9 @@ function parseArgs(argv) {
79
98
  pr: null,
80
99
  remoteOnly: false,
81
100
  mcp: null,
101
+ url: null,
82
102
  printRemote: false,
103
+ spaces: false,
83
104
  help: false,
84
105
  };
85
106
  for (let i = 0; i < argv.length; i++) {
@@ -89,6 +110,8 @@ function parseArgs(argv) {
89
110
  else if (t === "--remote-only") a.remoteOnly = true;
90
111
  else if (t === "--mcp") a.mcp = argv[++i];
91
112
  else if (t === "--print-remote") a.printRemote = true;
113
+ else if (t === "--spaces") a.spaces = true;
114
+ else if (t === "--url") a.url = argv[++i];
92
115
  else if (t === "--help" || t === "-h") a.help = true;
93
116
  // Positional, git-clone style: `tot clone <tenant> [<dir>]`.
94
117
  else if (!t.startsWith("--")) {
@@ -126,6 +149,13 @@ const USAGE = `tot clone — clone a tenant store you can build on (mirrors \`gi
126
149
  <tenant>-pr<N>)
127
150
  tot clone agency-kit [dir] clone the Agency Kit, READ-ONLY, into ./agency-kit
128
151
  (needs agency access — ask Token of Trust)
152
+ tot clone --spaces (inside a checkout) record the store's publication
153
+ spaces and clone any mounted space not yet under
154
+ spaces/<id>/
155
+
156
+ A store split across repositories (a root plus mounted spaces like /blog) clones
157
+ whole: the root, then each mounted space you can edit into spaces/<id>/.
158
+ \`tot validate --owner <path>\` names the repository that owns a URL.
129
159
 
130
160
  Options:
131
161
  --tag <tag> deprecated compatibility input; tenant repos use the bare domain.
@@ -134,6 +164,8 @@ Options:
134
164
  --remote-only don't clone; just mint + print the clone URL for <tenant>.
135
165
  --mcp <url> MCP base URL. Default: env MCP_BASE_URL / TOT_MCP_URL, else
136
166
  ${DEFAULT_MCP_URL}.
167
+ --url <origin> storefront origin the publication spaces are read from.
168
+ Default: env TOT_STOREFRONT_URL, else the shared storefront.
137
169
  --print-remote with --remote-only, also print the authenticated remote
138
170
  (contains a live token!).`;
139
171
 
@@ -154,7 +186,7 @@ function makeRedactor(env) {
154
186
  export async function run(argv, ctx, deps = {}) {
155
187
  const {
156
188
  env = process.env, createClient = createMcpClient, establish = establishSession,
157
- signIn = offerSignIn, cloneDeps = {},
189
+ signIn = offerSignIn, cloneDeps = {}, readTopology = fetchDeclaredTopology,
158
190
  } = deps;
159
191
  const args = parseArgs(argv);
160
192
  if (args.help) {
@@ -172,6 +204,24 @@ export async function run(argv, ctx, deps = {}) {
172
204
  args.tenant = ctx.tenant;
173
205
  }
174
206
 
207
+ if (args.spaces) {
208
+ if (ctx.mode !== "checkout" || !ctx.workspacePath || !args.tenant) {
209
+ console.error(fail("`tot clone --spaces` runs inside a store checkout", "tot clone <tenant> (clones the root and its spaces)"));
210
+ return 2;
211
+ }
212
+ const redact = makeRedactor(env);
213
+ const client = createClient(args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL);
214
+ try {
215
+ await signInOrOffer(client, { env, establish, signIn });
216
+ await clonePublicationSpacesInto(client, {
217
+ tenant: args.tenant, dir: ctx.workspacePath, env, storefrontUrl: args.url, redact, readTopology, cloneDeps,
218
+ });
219
+ return 0;
220
+ } catch (e) {
221
+ return reportFailure(e, redact);
222
+ }
223
+ }
224
+
175
225
  let pr;
176
226
  try {
177
227
  pr = parsePrNumber(args.pr);
@@ -246,11 +296,14 @@ export async function run(argv, ctx, deps = {}) {
246
296
  tag: args.tag,
247
297
  cloneDir,
248
298
  redact,
249
- });
299
+ }, cloneDeps);
250
300
  console.log(`\n+ ready. repo: ${res.cloneUrl || res.publicUrl}`);
251
301
 
252
302
  if (res.cloned) {
253
303
  console.log(`+ cloned. HEAD: ${res.head}`);
304
+ await clonePublicationSpacesInto(client, {
305
+ tenant: args.tenant, dir: /** @type {string} */ (res.dir), env, storefrontUrl: args.url, redact, readTopology, cloneDeps,
306
+ });
254
307
  console.log(`\nYour local working clone is at ${res.dir}.`);
255
308
  console.log(` cd ${res.dir} && tot dev # run it locally with save→reload`);
256
309
  console.log(` (origin has no token — \`tot\` mints one fresh at fetch/push time)`);
@@ -374,7 +427,7 @@ export function checkoutPointsAtEntitlement(checkout) {
374
427
  * @returns {Promise<{ gitRemote: string, cloneUrl: string|null, publicUrl: string,
375
428
  * cloned: boolean, dir: string|null, head: string|null }>}
376
429
  */
377
- export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
430
+ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }, cloneDeps = {}) {
378
431
  // tenant_checkout is the SOLE AUTHORITATIVE checkout decision, and it names the tenant
379
432
  // (scopeToTenant injects it), so the server checks out that held tenant directly — or
380
433
  // answers for a staff-preview authorization, or refuses naming the tenants the session
@@ -419,7 +472,7 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
419
472
  if (!cloneDir) {
420
473
  return { gitRemote, cloneUrl, publicUrl, cloned: false, dir: null, head: null };
421
474
  }
422
- const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact);
475
+ const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact, cloneDeps);
423
476
  return { gitRemote, cloneUrl, publicUrl, cloned: true, dir, head };
424
477
  }
425
478
 
@@ -515,6 +568,137 @@ async function cloneRepo(gitRemote, dir, redact, {
515
568
  return { dir, head };
516
569
  }
517
570
 
571
+ // ─── mounted publication spaces ────────────────────────────────────────────────
572
+
573
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
574
+
575
+ /**
576
+ * Read a store's declared publication topology from the platform: `GET /api/publication-spaces`
577
+ * over the developer's own viewer session, which returns only the spaces the platform lets this
578
+ * developer see. Never throws.
579
+ * @param {{ tenant: string, env?: NodeJS.ProcessEnv, storefrontUrl?: string|null,
580
+ * fetchImpl?: typeof fetch, resolveTransport?: typeof resolveViewerTransport }} input
581
+ * @returns {Promise<{ ok: true, topology: import("../publication-spaces.mjs").Topology } | { ok: false, reason: string }>}
582
+ */
583
+ export async function fetchDeclaredTopology({
584
+ tenant, env = process.env, storefrontUrl = null, fetchImpl = fetch, resolveTransport = resolveViewerTransport,
585
+ }) {
586
+ const origin = storefrontUrl || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
587
+ const transport = await resolveTransport({ tenant, env, fetchImpl, storefrontUrl: origin });
588
+ if (!transport.ok) return { ok: false, reason: transport.message };
589
+ let res;
590
+ try {
591
+ res = await fetchImpl(`${transport.base}/api/publication-spaces`, { headers: transport.authHeaders });
592
+ } catch (e) {
593
+ return { ok: false, reason: `couldn't reach ${transport.base}: ${/** @type {any} */ (e)?.message || e}` };
594
+ }
595
+ /** @type {any} */
596
+ let body = null;
597
+ try { body = await res.json(); } catch { /* not JSON */ }
598
+ if (!res.ok || !body?.ok) return { ok: false, reason: body?.error || `HTTP ${res.status}` };
599
+ try {
600
+ return { ok: true, topology: topologyFromDeclaredSpaces(tenant, body.spaces) };
601
+ } catch (e) {
602
+ return { ok: false, reason: String(/** @type {any} */ (e)?.message || e) };
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Mint a write credential for one mounted space through `publication_checkout`, refusing a
608
+ * result for any other space or repository. The credential is returned, never printed.
609
+ * @param {{ callTool: (name: string, args?: Record<string, unknown>) => Promise<any> }} scoped
610
+ * @param {import("../publication-spaces.mjs").TopologySpace} space
611
+ * @param {(s: string) => string} redact
612
+ * @returns {Promise<{ gitRemote: string }>}
613
+ */
614
+ export async function mintPublicationSpace(scoped, space, redact = (s) => s) {
615
+ const next = `ask the store owner for edit access to the "${space.id}" space, then run \`tot clone --spaces\` in your checkout`;
616
+ /** @type {any} */
617
+ let res;
618
+ try {
619
+ res = await scoped.callTool("publication_checkout", {});
620
+ } catch (e) {
621
+ const reason = serverReason(e);
622
+ throw new CliError(`no write credential for the "${space.id}" space${reason ? `: ${redact(reason)}` : ""}`, { next, cause: e });
623
+ }
624
+ if (!res || res.status !== "checked-out" || typeof res.gitRemote !== "string" || !res.gitRemote) {
625
+ const reason = serverReason(res);
626
+ throw new CliError(`no write credential for the "${space.id}" space${reason ? `: ${redact(reason)}` : ""}`, { next });
627
+ }
628
+ if (res.binding?.spaceId !== space.id || res.repo !== space.source.repo || res.binding?.source?.repo !== space.source.repo) {
629
+ throw new CliError(`Token of Trust has no write credential for the "${space.id}" space (it offered "${res.binding?.spaceId ?? "another space"}")`, { next });
630
+ }
631
+ return { gitRemote: res.gitRemote };
632
+ }
633
+
634
+ /**
635
+ * Clone each mounted space of `topology` that is not yet under `<dir>/spaces/` — a space this
636
+ * developer has no credential for is reported and skipped, never fatal to the root clone.
637
+ * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
638
+ * @param {{ tenant: string, dir: string, topology: import("../publication-spaces.mjs").Topology,
639
+ * redact?: (s: string) => string }} input
640
+ * @param {Parameters<typeof cloneRepo>[3]} [deps]
641
+ * @returns {Promise<Array<{ spaceId: string, mount: string, dir: string, status: "cloned"|"present"|"skipped",
642
+ * head?: string, reason?: string, next?: string }>>}
643
+ */
644
+ export async function clonePublicationSpaces(client, { tenant, dir, topology, redact = (s) => s }, deps = {}) {
645
+ const spaces = mountedSpaces(topology);
646
+ if (!spaces.length) return [];
647
+ const scoped = await scopeToTenant(client, tenant, { redact });
648
+ const results = [];
649
+ for (const space of spaces) {
650
+ const rel = spaceDir(space);
651
+ const target = join(dir, rel);
652
+ if (existsSync(target)) {
653
+ results.push({ spaceId: space.id, mount: space.mount, dir: rel, status: /** @type {const} */ ("present") });
654
+ continue;
655
+ }
656
+ console.error(`~ publication_checkout (tenant=${tenant}, space=${space.id}) — MCP mints a fresh space credential`);
657
+ try {
658
+ const { gitRemote } = await mintPublicationSpace(scoped, space, redact);
659
+ const cred = splitAuthedRemote(gitRemote);
660
+ const scrub = (/** @type {string} */ s) => (cred ? redact(s).split(cred.token).join("***") : redact(s));
661
+ const { head } = await cloneRepo(gitRemote, target, scrub, {
662
+ ...deps,
663
+ store: false,
664
+ record: (gitSync) => recordPublicationSpaceCheckout(gitSync, { tenant, spaceId: space.id, repo: space.source.repo }),
665
+ });
666
+ results.push({ spaceId: space.id, mount: space.mount, dir: rel, status: /** @type {const} */ ("cloned"), head });
667
+ } catch (e) {
668
+ const err = /** @type {any} */ (e);
669
+ results.push({
670
+ spaceId: space.id, mount: space.mount, dir: rel, status: /** @type {const} */ ("skipped"),
671
+ reason: err instanceof CliError ? err.message : redact(String(err?.message || err)),
672
+ ...(err?.next ? { next: err.next } : {}),
673
+ });
674
+ }
675
+ }
676
+ return results;
677
+ }
678
+
679
+ /**
680
+ * After a root clone (or `tot clone --spaces` in a checkout): record the declared topology and
681
+ * clone the mounted spaces, reporting each in one line. A topology the platform will not give is
682
+ * reported and the root stays usable on its own.
683
+ */
684
+ async function clonePublicationSpacesInto(client, { tenant, dir, env, storefrontUrl, redact, readTopology, cloneDeps }) {
685
+ const read = await readTopology({ tenant, env, storefrontUrl });
686
+ if (!read.ok) {
687
+ console.log(`~ publication spaces: couldn't read this store's split (${redact(read.reason)}) — the root is cloned on its own.`);
688
+ console.log(` Run \`tot clone --spaces\` in ${dir} to retry.`);
689
+ return;
690
+ }
691
+ writeWorkspaceTopology(dir, read.topology);
692
+ const mounted = mountedSpaces(read.topology);
693
+ if (!mounted.length) return;
694
+ console.log(`+ publication spaces recorded in ${TOPOLOGY_FILE}: ${mounted.map((s) => `${s.mount} (${s.id})`).join(", ")}`);
695
+ for (const result of await clonePublicationSpaces(client, { tenant, dir, topology: read.topology, redact }, cloneDeps)) {
696
+ if (result.status === "cloned") console.log(`+ space ${result.spaceId} (${result.mount}) cloned into ${result.dir}/. HEAD: ${result.head}`);
697
+ else if (result.status === "present") console.log(` space ${result.spaceId} (${result.mount}) already at ${result.dir}/`);
698
+ else console.log(`~ space ${result.spaceId} (${result.mount}) not cloned: ${result.reason}${result.next ? `\n next: ${result.next}` : ""}`);
699
+ }
700
+ }
701
+
518
702
  // ─── read-only aliases: `tot clone agency-kit` ─────────────────────────────────
519
703
 
520
704
  /**
@@ -17,10 +17,12 @@
17
17
  * sends when the forge rejected the credential, drops that cache so the next
18
18
  * `get` re-mints; `store` is a no-op.
19
19
  *
20
- * Two checkout kinds: a tenant store (its `.tot/config.json`) mints a push
20
+ * Three checkout kinds: a tenant store (its `.tot/config.json`) mints a push
21
21
  * credential through `tenant_checkout`; a read-only kit clone (recorded in local
22
22
  * git config by `tot clone agency-kit`) mints through `repo_read_credential`,
23
- * cached under a key a store's can never take.
23
+ * cached under a key a store's can never take; a mounted publication space (recorded
24
+ * by `tot clone` under `spaces/<id>/`) mints through `publication_checkout`, never
25
+ * cached, because each mint rotates the editor's older tokens.
24
26
  *
25
27
  * FAILS SILENT ON STDOUT, NEVER LOUD THERE: `get` prints NOTHING on stdout and
26
28
  * exits non-zero on any problem (not signed in, MCP unreachable, cwd isn't a
@@ -49,14 +51,15 @@ import { readFileSync } from "node:fs";
49
51
  import { execFileSync } from "node:child_process";
50
52
  import { createMcpClient } from "../mcp.mjs";
51
53
  import { establishSession } from "../auth.mjs";
52
- import { checkoutTenant, mintReadOnlyAlias, resolveReadOnlyAlias } from "./clone.mjs";
54
+ import { checkoutTenant, mintPublicationSpace, mintReadOnlyAlias, resolveReadOnlyAlias } from "./clone.mjs";
55
+ import { scopeToTenant } from "../tenant-scope.mjs";
53
56
  import { detectContext } from "../context.mjs";
54
57
  import { repoNameFromRemote, tagFromRepoName } from "./submit.mjs";
55
58
  import { CliError, formatError } from "../errors.mjs";
56
59
  import {
57
60
  parseCredentialInput, formatCredentialOutput, splitAuthedRemote,
58
61
  readCachedCredential, writeCachedCredential,
59
- kitCredentialCachePath, dropCachedCredential, readCheckoutKind, READ_ONLY_KIT_KIND,
62
+ kitCredentialCachePath, dropCachedCredential, readCheckoutKind, READ_ONLY_KIT_KIND, PUBLICATION_SPACE_KIND,
60
63
  } from "../git-credential.mjs";
61
64
 
62
65
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -115,14 +118,39 @@ export function readStdin() {
115
118
  * establish?: typeof establishSession,
116
119
  * checkout?: typeof checkoutTenant,
117
120
  * mintReadOnly?: typeof mintReadOnlyAlias,
121
+ * mintSpace?: typeof mintPublicationSpace,
118
122
  * }} [opts]
119
123
  * @returns {Promise<{username:string,password:string}|null>}
120
124
  */
121
125
  export async function mintOrCacheCredential({
122
126
  env = process.env, cwd = process.cwd(), expectedHost = null,
123
127
  createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
124
- mintReadOnly = mintReadOnlyAlias,
128
+ mintReadOnly = mintReadOnlyAlias, mintSpace = mintPublicationSpace,
125
129
  } = {}) {
130
+ const recorded = recordedCheckout(cwd);
131
+ if (recorded?.kind === "invalid") return null; // a half-recorded space mints nothing, never the store's
132
+ if (recorded?.kind === PUBLICATION_SPACE_KIND) {
133
+ if (expectedHost && !hostMatches(recorded.originUrl, expectedHost)) return null;
134
+ const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
135
+ const client = createClient(baseUrl);
136
+ try {
137
+ await establish(client, { env });
138
+ } catch {
139
+ return null;
140
+ }
141
+ try {
142
+ const scoped = await scopeToTenant(client, recorded.tenant);
143
+ const res = await mintSpace(scoped, {
144
+ id: recorded.spaceId, mount: "", visibility: "public",
145
+ source: { forge: "", repo: recorded.repo, defaultBranch: "" },
146
+ });
147
+ const cred = splitAuthedRemote(res.gitRemote || "");
148
+ return cred ? { username: cred.username, password: cred.token } : null;
149
+ } catch (e) {
150
+ if (e instanceof CliError) console.error(formatError(e));
151
+ return null;
152
+ }
153
+ }
126
154
  const kit = kitCheckout(cwd);
127
155
  if (kit) {
128
156
  if (expectedHost && !hostMatches(kit.originUrl, expectedHost)) return null;
@@ -219,6 +247,25 @@ export async function mintOrCacheCredential({
219
247
  }
220
248
  }
221
249
 
250
+ /**
251
+ * The publication-space checkout `cwd` is in (or a half-recorded one: `{ kind: "invalid" }`),
252
+ * from the kind `tot clone` recorded; null for anything else. Never throws.
253
+ * @param {string} cwd
254
+ * @returns {{ kind: "publication-space", tenant: string, spaceId: string, repo: string, originUrl: string|null }
255
+ * | { kind: "invalid" } | null}
256
+ */
257
+ export function recordedCheckout(cwd) {
258
+ const git = (cargs) =>
259
+ execFileSync("git", ["-C", cwd, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
260
+ let kind;
261
+ try { kind = readCheckoutKind(git); } catch { return null; }
262
+ if (kind.kind === "invalid") return { kind: "invalid" };
263
+ if (kind.kind !== PUBLICATION_SPACE_KIND) return null;
264
+ let originUrl = null;
265
+ try { originUrl = git(["remote", "get-url", "origin"]).trim(); } catch { /* host check fails closed */ }
266
+ return { ...kind, originUrl };
267
+ }
268
+
222
269
  /**
223
270
  * The read-only kit checkout `cwd` is in, from the kind `tot clone` recorded, or null when
224
271
  * it is not one (a store, a loose dir, not a repo). Never throws.
@@ -10,26 +10,34 @@
10
10
  * tot validate <tenant> (inside the monorepo) lint tenants/<tenant>/
11
11
  * tot validate --workspace DIR lint a specific checkout directory
12
12
  * tot validate --json machine-readable findings
13
+ * tot validate --owner <path> name the repository that owns a URL path
14
+ *
15
+ * A checkout whose tenant has mounted publication spaces (its `.tot/publication-spaces.json`, which
16
+ * `tot clone` records) also reports root files whose URL a mounted space owns.
13
17
  *
14
18
  * Exit 0 = no errors (warnings allowed); 1 = one or more errors; 2 = usage / no target.
15
19
  */
16
- import { existsSync } from "node:fs";
17
- import { join, resolve } from "node:path";
20
+ import { existsSync, readdirSync } from "node:fs";
21
+ import { join, relative, resolve, sep } from "node:path";
18
22
  import { validateTenant, ERROR, WARN, printLoudAdvisories } from "../validate.mjs";
19
23
  import { validateVisualParityClosure } from "../visual-parity.mjs";
20
24
  import { fail } from "../errors.mjs";
21
25
  import { tenantDirSegments } from "../tenant-dirs.mjs";
26
+ import {
27
+ describeOwner, readWorkspaceTopology, rootFilesOwnedByMounts, TOPOLOGY_FILE,
28
+ } from "../publication-spaces.mjs";
22
29
 
23
30
  const mergeFindings = (...groups) => [...new Map(groups.flat().map((item) => [JSON.stringify(item), item])).values()]
24
31
  .sort((a, b) => `${a.file}\0${a.rule}\0${a.message}`.localeCompare(`${b.file}\0${b.rule}\0${b.message}`));
25
32
 
26
33
  function parseArgs(argv) {
27
- const a = { tenant: null, workspace: null, json: false, help: false, profile: null, profileSpecified: false };
34
+ const a = { tenant: null, workspace: null, json: false, help: false, profile: null, profileSpecified: false, owner: null };
28
35
  for (let i = 0; i < argv.length; i++) {
29
36
  const t = argv[i];
30
37
  if (t === "--workspace") a.workspace = argv[++i];
31
38
  else if (t === "--profile") { a.profileSpecified = true; a.profile = argv[++i] ?? null; }
32
39
  else if (t === "--json") a.json = true;
40
+ else if (t === "--owner") a.owner = argv[++i] ?? "";
33
41
  else if (t === "--help" || t === "-h") a.help = true;
34
42
  else if (!t.startsWith("--") && !a.tenant) a.tenant = t;
35
43
  }
@@ -42,7 +50,70 @@ const USAGE = `tot validate — lint your store before you submit
42
50
  tot validate <tenant> (inside the monorepo) lint tenants/<tenant>/
43
51
  tot validate --workspace <dir> lint a specific checkout directory
44
52
  tot validate --profile visual-parity close portable page/asset/link migration scope
45
- tot validate --json machine-readable findings`;
53
+ tot validate --json machine-readable findings
54
+ tot validate --owner <path> which repository owns a URL path (the root, or a mounted space)`;
55
+
56
+ /** Every file under `<dir>/content/`, relative to `dir`. */
57
+ function contentFiles(dir) {
58
+ const out = [];
59
+ const walk = (abs) => {
60
+ let entries;
61
+ try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
62
+ for (const entry of entries) {
63
+ const full = join(abs, entry.name);
64
+ if (entry.isDirectory()) walk(full);
65
+ else if (entry.isFile()) out.push(relative(dir, full).split(sep).join("/"));
66
+ }
67
+ };
68
+ walk(join(dir, "content"));
69
+ return out.sort();
70
+ }
71
+
72
+ /**
73
+ * The workspace's recorded topology, or an error string when it is unreadable.
74
+ * @returns {{ topology: import("../publication-spaces.mjs").Topology | null } | { error: string }}
75
+ */
76
+ function topologyFor(dir) {
77
+ try {
78
+ return { topology: readWorkspaceTopology(dir) };
79
+ } catch (e) {
80
+ return { error: String(/** @type {any} */ (e)?.message || e) };
81
+ }
82
+ }
83
+
84
+ /** `tot validate --owner <path>`: name the repository that owns a URL path. */
85
+ function runOwner(args, dir) {
86
+ const read = topologyFor(dir);
87
+ if ("error" in read) {
88
+ console.error(fail(read.error, "re-run `tot clone <tenant>` to record the topology again"));
89
+ return 2;
90
+ }
91
+ if (!read.topology) {
92
+ console.error(fail(
93
+ `this checkout records no publication topology (${TOPOLOGY_FILE})`,
94
+ "`tot clone <tenant>` records it — until then every path is the root repository's",
95
+ ));
96
+ return 2;
97
+ }
98
+ const owner = describeOwner(read.topology, /** @type {string} */ (args.owner));
99
+ if (!owner) {
100
+ console.error(fail(`"${args.owner}" is not a URL path`, "tot validate --owner /blog/my-post"));
101
+ return 2;
102
+ }
103
+ if (args.json) {
104
+ console.log(JSON.stringify(owner, null, 2));
105
+ return 0;
106
+ }
107
+ if (!owner.spaceId) {
108
+ console.log(`${owner.path} is a platform path — no repository serves it.`);
109
+ return 0;
110
+ }
111
+ const kind = owner.mount === "/" ? "the root space" : `the mounted space "${owner.spaceId}" (mount ${owner.mount}${owner.via === "alias" ? ", via an alias" : ""})`;
112
+ console.log(`${owner.path} is owned by ${kind}`);
113
+ console.log(` repository: ${owner.repo}`);
114
+ console.log(` edit it in: ${owner.dir === "." ? "this checkout" : `${owner.dir}/`}`);
115
+ return 0;
116
+ }
46
117
 
47
118
  /**
48
119
  * Resolve the tenant directory + id + scope to validate, from args + context.
@@ -84,6 +155,8 @@ export function run(argv, ctx) {
84
155
  return 2;
85
156
  }
86
157
 
158
+ if (args.owner !== null) return runOwner(args, dir);
159
+
87
160
  if (args.profileSpecified && args.profile !== "visual-parity") {
88
161
  console.error(fail(`unknown validation profile: ${args.profile}`, "use --profile visual-parity"));
89
162
  return 2;
@@ -96,13 +169,21 @@ export function run(argv, ctx) {
96
169
  // arbitrary --workspace <dir> stays conservative (unless its config resolves the host).
97
170
  mode: args.workspace ? "workspace" : "monorepo",
98
171
  });
172
+ const topology = topologyFor(dir);
173
+ const spaceFindings = "error" in topology
174
+ ? [{ level: ERROR, rule: "publication-topology", file: TOPOLOGY_FILE, message: topology.error,
175
+ fix: "re-run `tot clone <tenant>` to record the topology again" }]
176
+ : topology.topology ? rootFilesOwnedByMounts(topology.topology, contentFiles(dir)) : [];
177
+ const withSpaces = spaceFindings.length
178
+ ? { ok: baseline.ok && !spaceFindings.some((item) => item.level === ERROR), findings: mergeFindings(baseline.findings, spaceFindings) }
179
+ : baseline;
99
180
  const result = args.profile === "visual-parity"
100
181
  ? (() => {
101
182
  const parity = validateVisualParityClosure(dir, { tenantId: target.tenantId ?? undefined });
102
- const findings = mergeFindings(baseline.findings, parity.findings);
183
+ const findings = mergeFindings(withSpaces.findings, parity.findings);
103
184
  return { ok: !findings.some((item) => item.level === ERROR), findings };
104
185
  })()
105
- : baseline;
186
+ : withSpaces;
106
187
  const { ok, findings } = result;
107
188
 
108
189
  if (args.json) {
@@ -151,11 +151,17 @@ export function dropCachedCredential(filePath) {
151
151
  }
152
152
 
153
153
  /** The checkout kinds `tot` records in a checkout's local git config. A tenant store
154
- * carries none (it is recognized by its `.tot/config.json`); a read-only kit clone is
155
- * recorded explicitly, because nothing else in it says what to mint. */
154
+ * carries none (it is recognized by its `.tot/config.json`); a read-only kit clone and a
155
+ * mounted publication space are recorded explicitly, because nothing else in them says what
156
+ * to mint — a space repository carries a `.tot/config.json` naming its tenant, which would
157
+ * otherwise mint the ROOT repository's credential for it. */
156
158
  export const CHECKOUT_KIND_KEY = "tot.checkoutKind";
157
159
  export const READ_TARGET_KEY = "tot.readTarget";
158
160
  export const READ_ONLY_KIT_KIND = "read-only-kit";
161
+ export const PUBLICATION_SPACE_KIND = "publication-space";
162
+ export const SPACE_TENANT_KEY = "tot.spaceTenant";
163
+ export const SPACE_ID_KEY = "tot.spaceId";
164
+ export const SPACE_REPO_KEY = "tot.spaceRepo";
159
165
 
160
166
  /**
161
167
  * Record in a freshly cloned checkout that it is a read-only kit clone of `target`, so
@@ -169,18 +175,43 @@ export function recordReadOnlyKitCheckout(git, target) {
169
175
  git(["config", "--local", READ_TARGET_KEY, target]);
170
176
  }
171
177
 
178
+ /**
179
+ * Record in a freshly cloned checkout that it is the mounted publication space `spaceId` of
180
+ * `tenant`, whose repository is `repo`, so `tot git-credential` mints its credential through
181
+ * `publication_checkout` and never as the tenant's root store. Throws on a git failure.
182
+ * @param {(cargs: string[]) => unknown} git a `git -C <checkout>` runner
183
+ * @param {{ tenant: string, spaceId: string, repo: string }} space
184
+ */
185
+ export function recordPublicationSpaceCheckout(git, { tenant, spaceId, repo }) {
186
+ git(["config", "--local", CHECKOUT_KIND_KEY, PUBLICATION_SPACE_KIND]);
187
+ git(["config", "--local", SPACE_TENANT_KEY, tenant]);
188
+ git(["config", "--local", SPACE_ID_KEY, spaceId]);
189
+ git(["config", "--local", SPACE_REPO_KEY, repo]);
190
+ }
191
+
172
192
  /**
173
193
  * Read which kind of checkout `git` runs in: `{ kind: "read-only-kit", target }` for a
174
- * recorded kit clone, else `{ kind: "store" }` (the default; the store path then applies
175
- * its own checks). An unrecorded or unreadable config is a store, never a kit.
194
+ * recorded kit clone, `{ kind: "publication-space", tenant, spaceId, repo }` for a recorded
195
+ * space, else `{ kind: "store" }` (the default; the store path then applies its own checks).
196
+ * An unrecorded config is a store. A space record missing a field is `{ kind: "invalid" }`:
197
+ * it must mint nothing, never fall back to the store's credential.
176
198
  * @param {(cargs: string[]) => string} git
177
- * @returns {{ kind: "read-only-kit", target: string } | { kind: "store" }}
199
+ * @returns {{ kind: "read-only-kit", target: string } |
200
+ * { kind: "publication-space", tenant: string, spaceId: string, repo: string } |
201
+ * { kind: "invalid" } | { kind: "store" }}
178
202
  */
179
203
  export function readCheckoutKind(git) {
180
204
  const get = (key) => {
181
205
  try { return String(git(["config", "--local", "--get", key])).trim(); } catch { return ""; }
182
206
  };
183
- if (get(CHECKOUT_KIND_KEY) !== READ_ONLY_KIT_KIND) return { kind: "store" };
207
+ const kind = get(CHECKOUT_KIND_KEY);
208
+ if (kind === PUBLICATION_SPACE_KIND) {
209
+ const tenant = get(SPACE_TENANT_KEY);
210
+ const spaceId = get(SPACE_ID_KEY);
211
+ const repo = get(SPACE_REPO_KEY);
212
+ return tenant && spaceId && repo ? { kind: PUBLICATION_SPACE_KIND, tenant, spaceId, repo } : { kind: "invalid" };
213
+ }
214
+ if (kind !== READ_ONLY_KIT_KIND) return { kind: "store" };
184
215
  const target = get(READ_TARGET_KEY);
185
216
  return target ? { kind: READ_ONLY_KIT_KIND, target } : { kind: "store" };
186
217
  }
@@ -24,6 +24,18 @@ export const SPACES_DIR = "spaces";
24
24
  export const TOPOLOGY_FILE = ".tot/publication-spaces.json";
25
25
  export const TOPOLOGY_VERSION = 1;
26
26
 
27
+ /**
28
+ * Platform-owned paths no space serves (packages/public-runtime PUBLICATION_SPACE_RESERVED_MOUNTS;
29
+ * the ownership parity test holds the two equal).
30
+ */
31
+ export const PLATFORM_PATHS = Object.freeze([
32
+ "/.well-known", "/__evidence", "/__preview", "/_a", "/_astro", "/_style-guide", "/admin", "/api",
33
+ "/apps", "/assets", "/auth", "/brand", "/capabilities", "/cockpit", "/dashboard", "/dev",
34
+ "/favicon.svg", "/health", "/js", "/feed.json", "/llms.txt", "/publication-discovery",
35
+ "/search-index.json", "/sitemap.xml", "/platform", "/preview", "/products", "/collections",
36
+ "/search", "/saved", "/robots.txt", "/shared", "/style-guide", "/tenants",
37
+ ]);
38
+
27
39
  const SPACE_ID = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
28
40
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
29
41
 
@@ -145,15 +157,17 @@ export function mountedSpaces(topology) {
145
157
  }
146
158
 
147
159
  /**
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.
160
+ * Which space owns a URL path: none for a platform path (`/api`, `/admin`, ...), else the longest
161
+ * canonical mount (or alias) that owns it, else the root. Null for a path that cannot be read as one.
150
162
  * @param {Topology} topology
151
163
  * @param {string} urlPath
152
- * @returns {{ space: TopologySpace, via: "mount"|"alias"|"root" } | null}
164
+ * @returns {{ space: TopologySpace, via: "mount"|"alias"|"root" } | { space: null, via: "platform" } | null}
153
165
  */
154
166
  export function owningSpace(topology, urlPath) {
155
167
  const path = normalizeRequestPath(urlPath);
156
168
  if (path === null) return null;
169
+ const folded = path.toLowerCase();
170
+ if (PLATFORM_PATHS.some((reserved) => mountMatchesPath(reserved, folded))) return { space: null, via: "platform" };
157
171
  /** @type {{ space: TopologySpace, via: "mount"|"alias", mount: string } | null} */
158
172
  let best = null;
159
173
  for (const space of topology.spaces) {
@@ -221,3 +235,73 @@ export function excludeFromRootRepo(workspace, patterns) {
221
235
  if (!missing.length) return;
222
236
  appendFileSync(file, `${current && !current.endsWith("\n") ? "\n" : ""}${missing.join("\n")}\n`);
223
237
  }
238
+
239
+ /** The post collections a space can carry; each is served under its base path (default: its name). */
240
+ export const COLLECTIONS = ["blog", "the-build"];
241
+
242
+ /**
243
+ * The URL path a ROOT checkout file renders at, for the files whose URL the layout fixes:
244
+ * `content/pages-html/<slug>.html` and `content/pages/<slug>.json` at `/<slug>`, a collection's
245
+ * files under its base path. Null for files with no URL of their own (theme, chrome, assets).
246
+ * @param {TopologySpace} root
247
+ * @param {string} relPath path relative to the root checkout
248
+ */
249
+ export function rootFileUrlPath(root, relPath) {
250
+ const rel = String(relPath).replace(/\\/g, "/");
251
+ let m = /^content\/pages-html\/(.+)\.html$/.exec(rel) ?? /^content\/pages\/(.+)\.json$/.exec(rel);
252
+ if (m) return `/${m[1]}`;
253
+ m = /^content\/([^/]+)\//.exec(rel);
254
+ if (m && COLLECTIONS.includes(m[1])) {
255
+ const base = root.collections?.[m[1]] ?? m[1];
256
+ return base ? `/${base.replace(/^\/+|\/+$/g, "")}` : null;
257
+ }
258
+ return null;
259
+ }
260
+
261
+ /**
262
+ * Root files whose URL a mounted space owns. Visitors get the space's page at that URL, never the
263
+ * root's, so the file is dead weight in the root and a change to it ships nowhere.
264
+ * @param {Topology} topology
265
+ * @param {string[]} rootFiles paths relative to the root checkout
266
+ * @returns {Array<{ level: "warn", rule: string, file: string, message: string, fix: string }>}
267
+ */
268
+ export function rootFilesOwnedByMounts(topology, rootFiles) {
269
+ const root = rootSpace(topology);
270
+ const findings = [];
271
+ for (const file of rootFiles) {
272
+ const urlPath = rootFileUrlPath(root, file);
273
+ if (!urlPath) continue;
274
+ const owner = owningSpace(topology, urlPath);
275
+ if (!owner || !owner.space || owner.space.mount === "/") continue;
276
+ findings.push({
277
+ level: /** @type {"warn"} */ ("warn"),
278
+ rule: "publication-space-owns-url",
279
+ file,
280
+ message: `${urlPath} is served by the mounted space "${owner.space.id}" (${owner.space.source.forge}:${owner.space.source.repo}, mount ${owner.space.mount}), so this root file never reaches a visitor.`,
281
+ fix: `move it into ${spaceDir(owner.space)}/ (the "${owner.space.id}" repository), or delete it from the root`,
282
+ });
283
+ }
284
+ return findings;
285
+ }
286
+
287
+ /**
288
+ * Who owns a URL path. `spaceId` is null for a platform path, which no repository serves.
289
+ * @param {Topology} topology @param {string} urlPath
290
+ * @returns {{ path: string, spaceId: string|null, mount: string|null, via: string, repo: string|null, dir: string|null } | null}
291
+ */
292
+ export function describeOwner(topology, urlPath) {
293
+ const owner = owningSpace(topology, urlPath);
294
+ if (!owner) return null;
295
+ const { space, via } = owner;
296
+ if (!space) {
297
+ return { path: /** @type {string} */ (normalizeRequestPath(urlPath)), spaceId: null, mount: null, via, repo: null, dir: null };
298
+ }
299
+ return {
300
+ path: /** @type {string} */ (normalizeRequestPath(urlPath)),
301
+ spaceId: space.id,
302
+ mount: space.mount,
303
+ via,
304
+ repo: `${space.source.forge}:${space.source.repo}`,
305
+ dir: spaceDir(space) || ".",
306
+ };
307
+ }
@@ -26,7 +26,8 @@ import { CliError } from "./errors.mjs";
26
26
  * Tools whose `tenant` argument is the tenant the call acts on. Action-harness tools get the
27
27
  * argument from `registerActionTool` (tot-mcp `src/modules/mcp/tools/action-tool.ts`), which
28
28
  * resolves it through `resolveCallTenant` and echoes the tenant it acted on in every success
29
- * result; `tenant_checkout` declares the same argument itself and echoes it too.
29
+ * result; `tenant_checkout` and `publication_checkout` declare the same argument themselves and
30
+ * echo the tenant too.
30
31
  */
31
32
  export const TENANT_SCOPED_TOOLS = Object.freeze(new Set([
32
33
  "candidate_open",
@@ -35,6 +36,7 @@ export const TENANT_SCOPED_TOOLS = Object.freeze(new Set([
35
36
  "preview_status",
36
37
  "promotion_rollback",
37
38
  "promotion_status",
39
+ "publication_checkout",
38
40
  "tenant_checkout",
39
41
  ]));
40
42