@tokenoftrust/cli 2.4.4 → 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/pr.mjs +67 -7
- package/src/commands/ship.mjs +146 -13
- package/src/publication-spaces.mjs +72 -0
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/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
|
/**
|
|
@@ -305,3 +305,75 @@ export function describeOwner(topology, urlPath) {
|
|
|
305
305
|
dir: spaceDir(space) || ".",
|
|
306
306
|
};
|
|
307
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
|
+
}
|