@tokenoftrust/cli 2.4.3 → 2.4.5
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/bin/tot.mjs +23 -1
- package/package.json +1 -1
- package/src/commands/clone.mjs +189 -5
- package/src/commands/git-credential.mjs +52 -5
- package/src/commands/pr.mjs +67 -7
- package/src/commands/ship.mjs +146 -13
- package/src/commands/validate.mjs +87 -6
- package/src/git-credential.mjs +37 -6
- package/src/publication-spaces.mjs +159 -3
- package/src/tenant-scope.mjs +3 -1
package/bin/tot.mjs
CHANGED
|
@@ -62,7 +62,7 @@ const COMMANDS = [
|
|
|
62
62
|
{ invocation: "dev", blurb: "run your store locally with save→reload" },
|
|
63
63
|
{ invocation: "preview", blurb: "push your store to a reviewable preview" },
|
|
64
64
|
{ invocation: "status", blurb: "the pipeline verdict for your active tenant — progressing / stuck / idle, and why" },
|
|
65
|
-
{ invocation: "ship", blurb: "publish the
|
|
65
|
+
{ invocation: "ship", blurb: "publish the store live — its green aggregate and approved mounted spaces as one release (plan → confirm → ship)" },
|
|
66
66
|
{ invocation: "accept / tot merge", blurb: "queue a PR into the preview aggregate — operator verb, no go-live (plan → confirm → integrate)" },
|
|
67
67
|
{ invocation: "sync", blurb: "fetch `preview` and merge it into your local branch (accept-conflict recovery)" },
|
|
68
68
|
{ invocation: "rollback [<version>]", blurb: "instant re-point to a prior live version (list → confirm → rollback)" },
|
|
@@ -81,6 +81,16 @@ const COMMANDS = [
|
|
|
81
81
|
{ invocation: "--version", blurb: "print the CLI version" },
|
|
82
82
|
];
|
|
83
83
|
|
|
84
|
+
/** Store-root verbs that never run inside a mounted publication space's checkout. */
|
|
85
|
+
const SPACE_REFUSED_COMMANDS = new Set(["preview", "submit", "deploy", "sync", "accept", "merge", "hotfix", "revert"]);
|
|
86
|
+
|
|
87
|
+
/** @param {string} cmd @param {string} spaceId */
|
|
88
|
+
function spaceCheckoutRefusal(cmd, spaceId) {
|
|
89
|
+
return `✗ this is the "${spaceId}" publication space's own repository — \`tot ${cmd}\` acts on the store's root repository.\n` +
|
|
90
|
+
" → commit and `git push` here (tot mints this space's credential on push); once its version is built and\n" +
|
|
91
|
+
" approved on the store's admin Publish tab, `tot ship` takes it live with the rest of the store.\n";
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
function usage() {
|
|
85
95
|
const col = Math.max(...COMMANDS.map((c) => `tot ${c.invocation}`.length)) + 2;
|
|
86
96
|
const lines = COMMANDS.map((c) => ` ${`tot ${c.invocation}`.padEnd(col)}${c.blurb}`).join("\n");
|
|
@@ -115,6 +125,18 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
115
125
|
return 2;
|
|
116
126
|
}
|
|
117
127
|
|
|
128
|
+
// A mounted publication space's checkout (spaces/<id>/, recorded by `tot clone`) is its own
|
|
129
|
+
// repository: the store-root verbs below would push it with the ROOT's credential or merge the
|
|
130
|
+
// root's preview branch into it. Refuse them there, naming the space's own path.
|
|
131
|
+
if (SPACE_REFUSED_COMMANDS.has(cmd)) {
|
|
132
|
+
const { recordedCheckout } = await import("../src/commands/git-credential.mjs");
|
|
133
|
+
const space = recordedCheckout(ctx.cwd);
|
|
134
|
+
if (space?.kind === "publication-space") {
|
|
135
|
+
process.stderr.write(spaceCheckoutRefusal(cmd, space.spaceId));
|
|
136
|
+
return 2;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
118
140
|
if (cmd === "start") {
|
|
119
141
|
const { run } = await import("../src/commands/start.mjs");
|
|
120
142
|
return run(rest, ctx);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.5",
|
|
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",
|
package/src/commands/clone.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
package/src/commands/pr.mjs
CHANGED
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
* `candidate_status` / `candidate_close` tools. Dependency-free (global fetch +
|
|
18
18
|
* `git`).
|
|
19
19
|
*
|
|
20
|
+
* A STORE SPLIT ACROSS REPOSITORIES. In a checkout that records mounted publication spaces
|
|
21
|
+
* (`tot clone`), `tot pr list` also routes every changed file — the root's and each
|
|
22
|
+
* `spaces/<id>/` checkout's — to the repository that ships it, lists each space repository's
|
|
23
|
+
* open candidates, and names any root change whose URL a mounted space serves (it would never
|
|
24
|
+
* reach a visitor).
|
|
25
|
+
*
|
|
20
26
|
* OPERATOR MODE. `tot pr list --tenant <t>` lists a tenant's OPEN
|
|
21
27
|
* candidate queue WITHOUT a checkout — the read-only companion to `tot ship --pr <N>
|
|
22
28
|
* --tenant <t>`. It reuses that command's exact transport: the SAME `GET /api/changes`
|
|
@@ -24,6 +30,9 @@
|
|
|
24
30
|
* `normalizeChangesQueue` from ship.mjs). `view`/`close` stay developer-only.
|
|
25
31
|
*/
|
|
26
32
|
import { execFileSync } from "node:child_process";
|
|
33
|
+
import {
|
|
34
|
+
readWorkspaceTopology, routeWorkspaceChanges, spaceDir, workspaceChangedFiles,
|
|
35
|
+
} from "../publication-spaces.mjs";
|
|
27
36
|
import { createMcpClient } from "../mcp.mjs";
|
|
28
37
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
29
38
|
import { fail } from "../errors.mjs";
|
|
@@ -294,6 +303,56 @@ export async function runPrListOperator({ tenant, secret, storefrontUrl = null }
|
|
|
294
303
|
return 0;
|
|
295
304
|
}
|
|
296
305
|
|
|
306
|
+
/**
|
|
307
|
+
* The per-repository view of a split store: each repository's changed files (not yet submitted)
|
|
308
|
+
* and, for a mounted space, its open candidates. Silent for a checkout that records no spaces.
|
|
309
|
+
* @param {string} workspace
|
|
310
|
+
* @param {{ scoped: { callTool: (name: string, args?: any) => Promise<any> }, storefrontUrl: string,
|
|
311
|
+
* tenant: string, git?: (dir: string, args: string[]) => string }} deps
|
|
312
|
+
*/
|
|
313
|
+
export async function printChangesByRepository(workspace, {
|
|
314
|
+
scoped, storefrontUrl, tenant,
|
|
315
|
+
git = (dir, cargs) => {
|
|
316
|
+
try {
|
|
317
|
+
return execFileSync("git", ["-C", dir, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
318
|
+
} catch {
|
|
319
|
+
return "";
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
}) {
|
|
323
|
+
let topology;
|
|
324
|
+
try {
|
|
325
|
+
topology = readWorkspaceTopology(workspace);
|
|
326
|
+
} catch (e) {
|
|
327
|
+
console.log(`\n ⚠ ${String(/** @type {any} */ (e)?.message || e)} — re-run \`tot clone --spaces\` to record it again.`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (!topology || topology.spaces.length < 2) return;
|
|
331
|
+
const routed = routeWorkspaceChanges(topology, workspaceChangedFiles(workspace, topology, git));
|
|
332
|
+
console.log("\nChanges by owning repository:");
|
|
333
|
+
for (const { space, files } of routed.groups) {
|
|
334
|
+
const where = space.mount === "/" ? "this checkout" : `${spaceDir(space)}/, mount ${space.mount}`;
|
|
335
|
+
console.log(` ${space.id} — ${space.source.forge}:${space.source.repo} (${where}): ` +
|
|
336
|
+
`${files.length ? `${files.length} changed file(s) not yet submitted` : "no local changes"}`);
|
|
337
|
+
for (const file of files) console.log(` ${file}`);
|
|
338
|
+
if (space.mount === "/") continue;
|
|
339
|
+
try {
|
|
340
|
+
const open = extractCandidates(await scoped.callTool("candidate_status", { repo: space.source.repo }));
|
|
341
|
+
for (const c of open) {
|
|
342
|
+
console.log(` ${formatCandidateLine({ ...c, previewUrl: c.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, c.prNumber) })}`);
|
|
343
|
+
}
|
|
344
|
+
} catch (e) {
|
|
345
|
+
console.log(` (couldn't read this repository's candidates: ${String(/** @type {any} */ (e)?.message || e)})`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for (const { file, owner } of routed.misrouted) {
|
|
349
|
+
console.log(` ⚠ ${file}: its URL is served by the "${owner.id}" space (mount ${owner.mount}), so this root change never reaches a visitor — move it to ${spaceDir(owner)}/`);
|
|
350
|
+
}
|
|
351
|
+
for (const file of routed.unowned) {
|
|
352
|
+
console.log(` ⚠ ${file}: no declared space owns this folder — it ships nowhere`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
297
356
|
/** @param {string[]} argv @param {any} ctx */
|
|
298
357
|
export async function run(argv, ctx) {
|
|
299
358
|
const env = process.env;
|
|
@@ -376,14 +435,15 @@ export async function run(argv, ctx) {
|
|
|
376
435
|
if (args.sub === "list") {
|
|
377
436
|
if (!candidates.length) {
|
|
378
437
|
console.log(`No open candidate PRs for ${repo}. Run \`tot submit\` to open one.`);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
438
|
+
} else {
|
|
439
|
+
const active = readActiveChangeId(statePath, scope);
|
|
440
|
+
console.log(`Open candidate PRs for ${repo}:`);
|
|
441
|
+
for (const c of candidates) {
|
|
442
|
+
const previewUrl = c.previewUrl ?? buildPreviewUrl(storefrontUrl, tenant, c.prNumber);
|
|
443
|
+
console.log(formatCandidateLine({ ...c, previewUrl }, { active: !!active && c.changeId === active }));
|
|
444
|
+
}
|
|
386
445
|
}
|
|
446
|
+
await printChangesByRepository(workspace, { scoped, storefrontUrl, tenant });
|
|
387
447
|
return 0;
|
|
388
448
|
}
|
|
389
449
|
|
package/src/commands/ship.mjs
CHANGED
|
@@ -36,10 +36,20 @@
|
|
|
36
36
|
* `promote_failed` / `record_failed` (a partial failure, recoverable by
|
|
37
37
|
* re-running `tot ship`, never a false live claim).
|
|
38
38
|
*
|
|
39
|
+
* A STORE SPLIT ACROSS REPOSITORIES ships as ONE release. Each mounted publication space (a
|
|
40
|
+
* `/blog` or `/docs` served from its own repository) publishes through its own reviewed path, so
|
|
41
|
+
* the plan also reads the tenant's declared spaces (`GET /api/publication-spaces`) and lists, per
|
|
42
|
+
* repository, what this release takes live: every space whose candidate is APPROVED and is not
|
|
43
|
+
* what live already serves is activated (`POST /api/publication-spaces/<id>/activate`, the same
|
|
44
|
+
* reviewed transition the admin Publish tab performs) under the same single confirm; a candidate
|
|
45
|
+
* still awaiting review is named as not in this release. The space list comes from the platform,
|
|
46
|
+
* never from the checkout.
|
|
47
|
+
*
|
|
39
48
|
* TRANSPORT: operators/CI may use a Bearer operator secret. A signed-in owner or
|
|
40
49
|
* developer instead mints a storefront viewer session from their OWN `tot login`
|
|
41
50
|
* identity; the server still enforces the live owner / `ship-on-behalf` grant.
|
|
42
51
|
*/
|
|
52
|
+
import { createHash } from "node:crypto";
|
|
43
53
|
import { fail } from "../errors.mjs";
|
|
44
54
|
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
45
55
|
import { startProgress } from "../progress.mjs";
|
|
@@ -81,6 +91,10 @@ const USAGE = `tot ship — publish the tenant's CURRENT GREEN AGGREGATE to live
|
|
|
81
91
|
VERIFIED live truth — never a false "shipped" — surfacing a refusal or a
|
|
82
92
|
recoverable partial failure honestly if it isn't.
|
|
83
93
|
|
|
94
|
+
A store split across repositories (a root plus mounted spaces such as /blog)
|
|
95
|
+
ships as ONE release: the plan lists each repository, and every space whose
|
|
96
|
+
candidate is approved is taken live under the same confirm.
|
|
97
|
+
|
|
84
98
|
The candidate-level merge is the separate \`tot accept\` / \`tot merge\` verb —
|
|
85
99
|
\`tot ship\` never merges.`;
|
|
86
100
|
|
|
@@ -294,6 +308,102 @@ export function refusalNextStep(reason) {
|
|
|
294
308
|
}
|
|
295
309
|
}
|
|
296
310
|
|
|
311
|
+
// ─── Mounted publication spaces in the release ──────────────────────────────────
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Split the tenant's declared spaces (`GET /api/publication-spaces` rows) into what this release
|
|
315
|
+
* takes live and what it leaves out. A space is READY when its candidate is approved and live
|
|
316
|
+
* does not already serve it; a candidate awaiting review (or with changes requested) is WAITING.
|
|
317
|
+
* A space with no candidate, or whose candidate is already live, has nothing to release. Pure.
|
|
318
|
+
* @param {any} rows
|
|
319
|
+
* @returns {{ ready: Array<{ spaceId: string, mount: string, versionId: string, sourceSha: string,
|
|
320
|
+
* liveVersionId: string|null, activate: { targetVersionId: string, expectedSourceSha: string,
|
|
321
|
+
* expectedArtifactDigest: string, expectedRevision: number } }>,
|
|
322
|
+
* waiting: Array<{ spaceId: string, mount: string, versionId: string, why: string }> }}
|
|
323
|
+
*/
|
|
324
|
+
export function spaceReleasePlan(rows) {
|
|
325
|
+
const ready = [];
|
|
326
|
+
const waiting = [];
|
|
327
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
328
|
+
const candidate = row?.candidate;
|
|
329
|
+
if (!candidate || typeof row.id !== "string") continue;
|
|
330
|
+
const mount = row.routes?.canonicalMount ?? row.canonicalMount ?? "?";
|
|
331
|
+
if (candidate.live?.state === "current") continue;
|
|
332
|
+
const decision = candidate.review?.decision;
|
|
333
|
+
const activate = candidate.actions?.activate;
|
|
334
|
+
if (decision === "approved" && activate && typeof activate.targetVersionId === "string") {
|
|
335
|
+
ready.push({
|
|
336
|
+
spaceId: row.id, mount, versionId: candidate.versionId, sourceSha: String(candidate.sourceSha ?? ""),
|
|
337
|
+
liveVersionId: candidate.live?.versionId ?? null, activate,
|
|
338
|
+
});
|
|
339
|
+
} else {
|
|
340
|
+
waiting.push({
|
|
341
|
+
spaceId: row.id, mount, versionId: candidate.versionId,
|
|
342
|
+
why: decision === "changes-requested" ? "changes requested in review" : "awaiting review",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { ready, waiting };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The plan lines naming each repository's part of the release. Pure. */
|
|
350
|
+
export function spaceReleaseLines(release) {
|
|
351
|
+
const lines = [];
|
|
352
|
+
if (release.ready.length) {
|
|
353
|
+
lines.push(" publication spaces going live in this release:");
|
|
354
|
+
for (const space of release.ready) {
|
|
355
|
+
lines.push(` ${space.mount} (${space.spaceId}): ${space.versionId} @ ${space.sourceSha.slice(0, 8)}` +
|
|
356
|
+
`${space.liveVersionId ? ` — replaces live ${space.liveVersionId}` : " — first live version"}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (release.waiting.length) {
|
|
360
|
+
lines.push(" not in this release:");
|
|
361
|
+
for (const space of release.waiting) {
|
|
362
|
+
lines.push(` ${space.mount} (${space.spaceId}): ${space.versionId} — ${space.why} (review it on the store's admin Publish tab)`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return lines;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A deterministic transition id, so a retried ship of the same release is idempotent. Pure. */
|
|
369
|
+
export function spaceTransitionId(space) {
|
|
370
|
+
const digest = createHash("sha256")
|
|
371
|
+
.update(`${space.spaceId}\0${space.activate.targetVersionId}\0${space.activate.expectedRevision}`)
|
|
372
|
+
.digest("hex").slice(0, 24);
|
|
373
|
+
return `cli-ship.${digest}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Read the tenant's declared spaces for the release; null when the store reports none. Never throws. */
|
|
377
|
+
async function readSpaceRelease({ base, authHeaders, fetchImpl }) {
|
|
378
|
+
try {
|
|
379
|
+
const res = await fetchImpl(`${base}/api/publication-spaces`, { method: "GET", headers: authHeaders });
|
|
380
|
+
const body = await readJsonSafe(res);
|
|
381
|
+
if (!res.ok || body?.ok !== true) return null;
|
|
382
|
+
return spaceReleasePlan(body.spaces);
|
|
383
|
+
} catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Activate one approved space candidate; returns a one-line honest outcome. Never throws. */
|
|
389
|
+
async function activateSpace({ base, authHeaders, fetchImpl, space }) {
|
|
390
|
+
try {
|
|
391
|
+
const res = await fetchImpl(`${base}/api/publication-spaces/${encodeURIComponent(space.spaceId)}/activate`, {
|
|
392
|
+
method: "POST",
|
|
393
|
+
headers: { "content-type": "application/json", ...authHeaders },
|
|
394
|
+
body: JSON.stringify({ transitionId: spaceTransitionId(space), ...space.activate }),
|
|
395
|
+
});
|
|
396
|
+
const body = await readJsonSafe(res);
|
|
397
|
+
if (res.ok && body?.ok === true) return { ok: true, line: ` ✓ ${space.mount} (${space.spaceId}) is live: ${space.versionId}` };
|
|
398
|
+
if (body?.committed === true) {
|
|
399
|
+
return { ok: false, line: ` ⏳ ${space.mount} (${space.spaceId}): selected ${space.versionId}; delivery is still confirming — re-run \`tot ship\` to confirm` };
|
|
400
|
+
}
|
|
401
|
+
return { ok: false, line: ` ✗ ${space.mount} (${space.spaceId}) did not go live: ${body?.error || `HTTP ${res.status}`}` };
|
|
402
|
+
} catch (e) {
|
|
403
|
+
return { ok: false, line: ` ✗ ${space.mount} (${space.spaceId}) did not go live: ${String(/** @type {any} */ (e)?.message || e)}` };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
297
407
|
/** Read a fetch Response body as JSON, tolerating a non-JSON/empty body. */
|
|
298
408
|
async function readJsonSafe(res) {
|
|
299
409
|
try {
|
|
@@ -386,7 +496,9 @@ export async function runShip(
|
|
|
386
496
|
return 1;
|
|
387
497
|
}
|
|
388
498
|
const plan = normalizeShipPlan(planData);
|
|
389
|
-
|
|
499
|
+
// The tenant's mounted publication spaces ride the same release (see the file header).
|
|
500
|
+
const spaces = (await readSpaceRelease({ base, authHeaders, fetchImpl })) ?? { ready: [], waiting: [] };
|
|
501
|
+
if (!plan.ok && !spaces.ready.length) {
|
|
390
502
|
console.error(fail(plan.message, refusalNextStep(plan.reason)));
|
|
391
503
|
// A not-green / no-passed-run refusal is exactly what the doctor diagnoses —
|
|
392
504
|
// name the changes keeping the aggregate red right here.
|
|
@@ -397,23 +509,28 @@ export async function runShip(
|
|
|
397
509
|
// 2. Print the EXACT plan via the shared affordance, then require ONE
|
|
398
510
|
// explicit confirm (`--yes` non-interactive; a non-TTY without it refuses).
|
|
399
511
|
const liveUrl = liveUrlFor(tenant);
|
|
400
|
-
const planLines =
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
512
|
+
const planLines = plan.ok
|
|
513
|
+
? planForAction({
|
|
514
|
+
action: "ship",
|
|
515
|
+
tenant,
|
|
516
|
+
pinnedSha: plan.pinnedSha,
|
|
517
|
+
artifactDigest: plan.artifactDigest,
|
|
518
|
+
includedPrs: plan.includedPrs,
|
|
519
|
+
rollbackTarget: plan.rollbackTarget,
|
|
520
|
+
paywall: plan.paywall,
|
|
521
|
+
targets: { live: liveUrl },
|
|
522
|
+
})
|
|
523
|
+
: [` whole-site aggregate: not in this release — ${plan.message}`];
|
|
524
|
+
planLines.push(...spaceReleaseLines(spaces));
|
|
410
525
|
if (acceptQualityReason) {
|
|
411
526
|
planLines.push(" quality findings: accept this exact revision and continue");
|
|
412
527
|
planLines.push(` acceptance reason: ${acceptQualityReason.trim() || "(missing)"}`);
|
|
413
528
|
}
|
|
414
529
|
const { confirmed, reason } = await confirmPlan(planLines, {
|
|
415
530
|
yes,
|
|
416
|
-
question:
|
|
531
|
+
question: spaces.ready.length
|
|
532
|
+
? `Ship this release of ${tenant} live (${[...(plan.ok ? ["the green aggregate"] : []), ...spaces.ready.map((s) => s.mount)].join(", ")})?`
|
|
533
|
+
: `Ship ${tenant}'s current green aggregate live?`,
|
|
417
534
|
});
|
|
418
535
|
if (!confirmed) {
|
|
419
536
|
if (reason === "non-tty") {
|
|
@@ -459,6 +576,21 @@ export async function runShip(
|
|
|
459
576
|
console.log(" ✓ quality findings accepted for this revision; continuing the publish.");
|
|
460
577
|
}
|
|
461
578
|
|
|
579
|
+
// Each approved space goes live through its own reviewed transition, under the confirm above.
|
|
580
|
+
const releaseSpaces = async () => {
|
|
581
|
+
let allLive = true;
|
|
582
|
+
for (const space of spaces.ready) {
|
|
583
|
+
const outcome = await activateSpace({ base, authHeaders, fetchImpl, space });
|
|
584
|
+
if (!outcome.ok) allLive = false;
|
|
585
|
+
(outcome.ok ? console.log : console.error)(outcome.line);
|
|
586
|
+
}
|
|
587
|
+
return allLive;
|
|
588
|
+
};
|
|
589
|
+
if (!plan.ok) {
|
|
590
|
+
console.log(`\n Shipping ${tenant}'s publication spaces (the whole-site aggregate is not in this release):`);
|
|
591
|
+
return (await releaseSpaces()) ? 0 : 1;
|
|
592
|
+
}
|
|
593
|
+
|
|
462
594
|
// 3. SHIP — the human go-live gate (confirmGoLive) plus the reviewed pin, so a
|
|
463
595
|
// STALE plan (the aggregate moved / the artifact changed since GET) is
|
|
464
596
|
// refused rather than shipping something nobody reviewed.
|
|
@@ -504,7 +636,8 @@ export async function runShip(
|
|
|
504
636
|
|
|
505
637
|
const code = reportShipResult(shipResult, { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
|
|
506
638
|
if (code !== 0) await surfaceDoctor();
|
|
507
|
-
|
|
639
|
+
const spacesLive = spaces.ready.length ? await releaseSpaces() : true;
|
|
640
|
+
return code !== 0 ? code : spacesLive ? 0 : 1;
|
|
508
641
|
}
|
|
509
642
|
|
|
510
643
|
/**
|
|
@@ -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(
|
|
183
|
+
const findings = mergeFindings(withSpaces.findings, parity.findings);
|
|
103
184
|
return { ok: !findings.some((item) => item.level === ERROR), findings };
|
|
104
185
|
})()
|
|
105
|
-
:
|
|
186
|
+
: withSpaces;
|
|
106
187
|
const { ok, findings } = result;
|
|
107
188
|
|
|
108
189
|
if (args.json) {
|
package/src/git-credential.mjs
CHANGED
|
@@ -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
|
|
155
|
-
* recorded explicitly, because nothing else in
|
|
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,
|
|
175
|
-
*
|
|
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 } |
|
|
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
|
-
|
|
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:
|
|
149
|
-
*
|
|
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,145 @@ 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
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Route changed workspace files to the repository that ships them. Paths are relative to the
|
|
311
|
+
* root checkout (a space's files as `spaces/<id>/...`). Each file lands in its owning space's
|
|
312
|
+
* group; a root file whose URL a mounted space owns is ALSO named as misrouted (it ships with the
|
|
313
|
+
* root and still never reaches a visitor); a file under `spaces/` naming no declared space is
|
|
314
|
+
* unowned. Pure.
|
|
315
|
+
* @param {Topology} topology
|
|
316
|
+
* @param {string[]} files
|
|
317
|
+
* @returns {{ groups: Array<{ space: TopologySpace, files: string[] }>,
|
|
318
|
+
* misrouted: Array<{ file: string, owner: TopologySpace }>, unowned: string[] }}
|
|
319
|
+
*/
|
|
320
|
+
export function routeWorkspaceChanges(topology, files) {
|
|
321
|
+
const root = rootSpace(topology);
|
|
322
|
+
/** @type {Map<string, string[]>} */
|
|
323
|
+
const byId = new Map(topology.spaces.map((space) => [space.id, []]));
|
|
324
|
+
const misrouted = [];
|
|
325
|
+
const unowned = [];
|
|
326
|
+
for (const file of files) {
|
|
327
|
+
const owner = spaceForWorkspaceFile(topology, file);
|
|
328
|
+
if (!owner) { unowned.push(file); continue; }
|
|
329
|
+
/** @type {string[]} */ (byId.get(owner.space.id)).push(file);
|
|
330
|
+
if (owner.space.mount === "/") {
|
|
331
|
+
const urlPath = rootFileUrlPath(root, file);
|
|
332
|
+
const served = urlPath ? owningSpace(topology, urlPath) : null;
|
|
333
|
+
if (served?.space && served.space.mount !== "/") misrouted.push({ file, owner: served.space });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
groups: topology.spaces.map((space) => ({ space, files: /** @type {string[]} */ (byId.get(space.id)) })),
|
|
338
|
+
misrouted,
|
|
339
|
+
unowned,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Parse `git status --porcelain=v1 -z` into changed paths (a rename yields its new path). Pure.
|
|
345
|
+
* @param {string} out
|
|
346
|
+
* @returns {string[]}
|
|
347
|
+
*/
|
|
348
|
+
export function parsePorcelainZ(out) {
|
|
349
|
+
const parts = String(out).split("\0");
|
|
350
|
+
const paths = [];
|
|
351
|
+
for (let i = 0; i < parts.length; i++) {
|
|
352
|
+
const entry = parts[i];
|
|
353
|
+
if (!entry || entry.length < 4) continue;
|
|
354
|
+
const status = entry.slice(0, 2);
|
|
355
|
+
paths.push(entry.slice(3));
|
|
356
|
+
if (status[0] === "R" || status[0] === "C") i++; // the next field is the rename's source
|
|
357
|
+
}
|
|
358
|
+
return paths;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Every changed file in a workspace, relative to the root checkout: the root repository's own
|
|
363
|
+
* changes plus each cloned space's (`spaces/<id>/...`). `git(dir, args)` runs git in `dir`.
|
|
364
|
+
* @param {string} workspace
|
|
365
|
+
* @param {Topology} topology
|
|
366
|
+
* @param {(dir: string, args: string[]) => string} git
|
|
367
|
+
* @returns {string[]}
|
|
368
|
+
*/
|
|
369
|
+
export function workspaceChangedFiles(workspace, topology, git) {
|
|
370
|
+
const files = parsePorcelainZ(git(workspace, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]));
|
|
371
|
+
for (const space of mountedSpaces(topology)) {
|
|
372
|
+
const dir = join(workspace, spaceDir(space));
|
|
373
|
+
if (!existsSync(join(dir, ".git"))) continue;
|
|
374
|
+
for (const file of parsePorcelainZ(git(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]))) {
|
|
375
|
+
files.push(`${spaceDir(space)}/${file}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return files;
|
|
379
|
+
}
|
package/src/tenant-scope.mjs
CHANGED
|
@@ -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`
|
|
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
|
|