@tokenoftrust/cli 1.4.0-rc.15 → 1.4.0-rc.16
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 +57 -0
- package/package.json +1 -1
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/ship.mjs +4 -3
- package/src/commands/submit.mjs +457 -94
- package/src/commands/sync.mjs +192 -0
- package/src/plan.mjs +75 -2
package/src/commands/submit.mjs
CHANGED
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
* Dependency-free (global fetch + `git`).
|
|
41
41
|
*/
|
|
42
42
|
import { execFileSync } from "node:child_process";
|
|
43
|
+
import { readFileSync } from "node:fs";
|
|
43
44
|
import { createHash } from "node:crypto";
|
|
44
45
|
import { setTimeout as delay } from "node:timers/promises";
|
|
45
46
|
import { createMcpClient } from "../mcp.mjs";
|
|
@@ -60,6 +61,13 @@ import {
|
|
|
60
61
|
|
|
61
62
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
62
63
|
|
|
64
|
+
// The public storefront origin a shareable preview PR URL is composed against
|
|
65
|
+
// (see shareablePrUrl, below) — the SAME default `tot ship` uses (ship.mjs's
|
|
66
|
+
// DEFAULT_STOREFRONT_URL), so a preview link and a ship link always agree on
|
|
67
|
+
// which storefront they point at even though submit.mjs and ship.mjs never
|
|
68
|
+
// import from each other.
|
|
69
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
70
|
+
|
|
63
71
|
// The git ref prefix an isolated candidate push lands under (b03 — stop
|
|
64
72
|
// force-pushing the SHARED `preview` ref). ONE constant so a rename is trivial —
|
|
65
73
|
// provisionally coordinated with the MCP-side candidate_open resolution (b02/b04),
|
|
@@ -77,7 +85,7 @@ export function parseArgs(argv) {
|
|
|
77
85
|
// `ref: null` — an explicit `--ref` always wins; otherwise the push target is
|
|
78
86
|
// derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
|
|
79
87
|
// never a fixed shared default.
|
|
80
|
-
const a = { mcp: null, identity: null, ref: null, skipValidate: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, new: false, help: false };
|
|
88
|
+
const a = { mcp: null, identity: null, ref: null, skipValidate: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, json: false, new: false, help: false };
|
|
81
89
|
for (let i = 0; i < argv.length; i++) {
|
|
82
90
|
const t = argv[i];
|
|
83
91
|
if (t === "--mcp") a.mcp = argv[++i];
|
|
@@ -85,6 +93,8 @@ export function parseArgs(argv) {
|
|
|
85
93
|
else if (t === "--ref") a.ref = argv[++i];
|
|
86
94
|
else if (t === "-m" || t === "--message") a.message = argv[++i];
|
|
87
95
|
else if (t === "--summary") a.summary = argv[++i];
|
|
96
|
+
else if (t === "--summary-file") a.summaryFile = argv[++i];
|
|
97
|
+
else if (t === "--json") a.json = true;
|
|
88
98
|
else if (t === "--skip-validate") a.skipValidate = true;
|
|
89
99
|
else if (t === "--no-commit") a.noCommit = true;
|
|
90
100
|
else if (t === "--no-wait") a.noWait = true;
|
|
@@ -113,8 +123,16 @@ export function renderUsage(verb = "preview") {
|
|
|
113
123
|
tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
|
|
114
124
|
tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
|
|
115
125
|
tot ${verb} --summary "<text>" longer description to accompany the title
|
|
126
|
+
tot ${verb} --summary-file <path> structured summary from a file — JSON
|
|
127
|
+
{intent,effect,verification,risk} or the labeled
|
|
128
|
+
Intent / User-visible effect / Verification /
|
|
129
|
+
Risk-rollback text block; pass "-" to read stdin
|
|
130
|
+
(mutually exclusive with --summary)
|
|
116
131
|
tot ${verb} --no-wait push and exit without polling for the reconcile result
|
|
117
132
|
tot ${verb} --no-open don't open the preview URL in the browser on success
|
|
133
|
+
tot ${verb} --json machine-readable result on stdout (candidate id, PR,
|
|
134
|
+
head SHA, preview URL, reconcile/compliance evidence)
|
|
135
|
+
— implies --no-open, no spinner
|
|
118
136
|
tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
119
137
|
|
|
120
138
|
By default a re-run UPDATES your open candidate PR (like pushing more commits
|
|
@@ -161,14 +179,135 @@ export function buildChangeSummary({ message, summary, headSubject = "", statLin
|
|
|
161
179
|
|
|
162
180
|
/** Print the change summary block — the SAME title/body carried into `candidate_open`
|
|
163
181
|
* (below) as the PR title/description, so what the approver reads in the PR matches
|
|
164
|
-
* what's printed here.
|
|
165
|
-
|
|
182
|
+
* what's printed here. `quiet` (--json) suppresses the human-readable print — the
|
|
183
|
+
* same data reaches the caller via the JSON result instead (see buildJsonResult). */
|
|
184
|
+
function printChangeSummary({ title, body, autoTitle }, { quiet = false } = {}) {
|
|
185
|
+
if (quiet) return;
|
|
166
186
|
console.log(`\n Change summary (for the approver / the change record):`);
|
|
167
187
|
console.log(` ${title}`);
|
|
168
188
|
for (const l of body) console.log(` ${l}`);
|
|
169
189
|
if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
|
|
170
190
|
}
|
|
171
191
|
|
|
192
|
+
// ─── structured candidate summary (--summary-file / stdin, P2 item 14) ──────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The four fields the candidate body prefers (see
|
|
196
|
+
* docs/architecture/branch-lifecycle-and-integration-preview.md §"Change
|
|
197
|
+
* descriptions and AI assistance") — `key` is the JSON key, `label` the
|
|
198
|
+
* canonical text-block heading, `match` the label spellings
|
|
199
|
+
* parseLabeledSummary recognizes for that field (case-insensitive).
|
|
200
|
+
*/
|
|
201
|
+
const STRUCTURED_FIELDS = [
|
|
202
|
+
{ key: "intent", label: "Intent", match: /^intent$/i },
|
|
203
|
+
{ key: "effect", label: "User-visible effect", match: /^(user-visible effect|effect)$/i },
|
|
204
|
+
{ key: "verification", label: "Verification", match: /^verification$/i },
|
|
205
|
+
{ key: "risk", label: "Risk / rollback", match: /^(risk\s*\/?\s*rollback|risk-rollback|risk)$/i },
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Parse the labeled four-field text block (`Intent: …` / `User-visible effect: …`
|
|
210
|
+
* / `Verification: …` / `Risk / rollback: …`) into `{intent, effect, verification,
|
|
211
|
+
* risk}` — a field's value continues across following lines until the next
|
|
212
|
+
* recognized label, so multi-line prose under one label is preserved. Unlabeled
|
|
213
|
+
* leading text (and anything before the first recognized label) is dropped —
|
|
214
|
+
* callers fall back to treating the whole file as freeform body when nothing
|
|
215
|
+
* matches at all. Pure — unit-tested.
|
|
216
|
+
* @param {string} text
|
|
217
|
+
* @returns {{intent?: string, effect?: string, verification?: string, risk?: string}}
|
|
218
|
+
*/
|
|
219
|
+
export function parseLabeledSummary(text) {
|
|
220
|
+
const fields = {};
|
|
221
|
+
let current = null;
|
|
222
|
+
let buf = [];
|
|
223
|
+
const flush = () => {
|
|
224
|
+
if (current) fields[current] = buf.join("\n").trim();
|
|
225
|
+
buf = [];
|
|
226
|
+
};
|
|
227
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
228
|
+
const m = /^([A-Za-z][A-Za-z /-]*?)\s*:\s*(.*)$/.exec(line);
|
|
229
|
+
const field = m && STRUCTURED_FIELDS.find((f) => f.match.test(m[1].trim()));
|
|
230
|
+
if (field) {
|
|
231
|
+
flush();
|
|
232
|
+
current = field.key;
|
|
233
|
+
buf = m[2] ? [m[2]] : [];
|
|
234
|
+
} else if (current) {
|
|
235
|
+
buf.push(line);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
flush();
|
|
239
|
+
return fields;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Parse `--summary-file` content as JSON — `{intent, effect, verification, risk}`
|
|
244
|
+
* (extra keys ignored, each value trimmed, blank/non-string values dropped).
|
|
245
|
+
* Returns null when the text isn't a JSON object at all, so the caller falls
|
|
246
|
+
* back to the labeled-text parser rather than treating a JSON parse error as
|
|
247
|
+
* "no fields". Pure — unit-tested.
|
|
248
|
+
* @param {string} text
|
|
249
|
+
* @returns {{intent?: string, effect?: string, verification?: string, risk?: string}|null}
|
|
250
|
+
*/
|
|
251
|
+
export function parseJsonSummary(text) {
|
|
252
|
+
let obj;
|
|
253
|
+
try {
|
|
254
|
+
obj = JSON.parse(text);
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
|
|
259
|
+
const fields = {};
|
|
260
|
+
for (const { key } of STRUCTURED_FIELDS) {
|
|
261
|
+
if (typeof obj[key] === "string" && obj[key].trim()) fields[key] = obj[key].trim();
|
|
262
|
+
}
|
|
263
|
+
return fields;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Render recognized structured fields back to the canonical labeled block — the
|
|
268
|
+
* SAME shape the contract prescribes — so a JSON or labeled-text `--summary-file`
|
|
269
|
+
* produces an identical PR/candidate body to a human writing
|
|
270
|
+
* `--summary "Intent: …"` by hand. Only fields actually present are emitted.
|
|
271
|
+
* Pure — unit-tested.
|
|
272
|
+
* @param {{intent?: string, effect?: string, verification?: string, risk?: string}} fields
|
|
273
|
+
* @returns {string[]}
|
|
274
|
+
*/
|
|
275
|
+
export function formatStructuredSummary(fields) {
|
|
276
|
+
return STRUCTURED_FIELDS.filter(({ key }) => fields[key]).map(({ key, label }) => `${label}: ${fields[key]}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Resolve `--summary-file`/stdin content to `--summary`'s body text (so it
|
|
281
|
+
* flows through buildChangeSummary/candidate_open exactly like an inline
|
|
282
|
+
* `--summary`, per the "preserve -m/--summary unchanged" requirement — this is
|
|
283
|
+
* purely an alternate SOURCE for the same body string). JSON wins if the
|
|
284
|
+
* content parses as an object with at least one recognized field; else the
|
|
285
|
+
* labeled text block; else — no recognized structure at all — the raw content
|
|
286
|
+
* is used verbatim as freeform summary text, so automation isn't forced into
|
|
287
|
+
* the four-field shape. Pure — unit-tested.
|
|
288
|
+
* @param {string} text
|
|
289
|
+
* @returns {string}
|
|
290
|
+
*/
|
|
291
|
+
export function summaryFromStructuredText(text) {
|
|
292
|
+
const trimmed = String(text ?? "").trim();
|
|
293
|
+
if (!trimmed) return "";
|
|
294
|
+
const json = parseJsonSummary(trimmed);
|
|
295
|
+
if (json && Object.keys(json).length) return formatStructuredSummary(json).join("\n");
|
|
296
|
+
const labeled = parseLabeledSummary(trimmed);
|
|
297
|
+
if (Object.keys(labeled).length) return formatStructuredSummary(labeled).join("\n");
|
|
298
|
+
return trimmed;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Read `--summary-file <path>` content — `"-"` reads stdin (fd 0), the same
|
|
302
|
+
* dash-means-stdin convention `tot app` uses (see app/dev.mjs readInput), so
|
|
303
|
+
* an LLM/automation caller can pipe the structured summary in without a temp
|
|
304
|
+
* file. Throws on a real read failure (missing file, permissions) — the
|
|
305
|
+
* caller reports it. */
|
|
306
|
+
export function readSummaryFileContent(pathOrDash) {
|
|
307
|
+
if (pathOrDash === "-") return readFileSync(0, "utf8");
|
|
308
|
+
return readFileSync(pathOrDash, "utf8");
|
|
309
|
+
}
|
|
310
|
+
|
|
172
311
|
// ─── auto-commit the known content trees (unit u2) ───────────────────────────────
|
|
173
312
|
|
|
174
313
|
/**
|
|
@@ -580,17 +719,19 @@ export function resolvePushRef({ ref, changeId }) {
|
|
|
580
719
|
* landed or the reconcile/compliance read-back that follows.
|
|
581
720
|
* @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
|
|
582
721
|
* @param {{ repo: string|null, changeId: string, changeSummary: {title:string, body:string[]},
|
|
583
|
-
* patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer
|
|
722
|
+
* patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer,
|
|
723
|
+
* quiet?: boolean }} opts `quiet` (--json) suppresses the human print; the same
|
|
724
|
+
* result is still returned for the caller's JSON payload.
|
|
584
725
|
*/
|
|
585
|
-
export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob }) {
|
|
726
|
+
export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet = false }) {
|
|
586
727
|
if (!repo) {
|
|
587
|
-
console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
|
|
728
|
+
if (!quiet) console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
|
|
588
729
|
return null;
|
|
589
730
|
}
|
|
590
731
|
try {
|
|
591
732
|
const patch = buildFilePatch(patchEntries, readBlob);
|
|
592
733
|
if (patch.length === 0) {
|
|
593
|
-
console.log(` ~ no file changes to open a PR-backed candidate for.`);
|
|
734
|
+
if (!quiet) console.log(` ~ no file changes to open a PR-backed candidate for.`);
|
|
594
735
|
return null;
|
|
595
736
|
}
|
|
596
737
|
const result = await client.callTool("candidate_open", {
|
|
@@ -603,18 +744,21 @@ export async function submitCandidate(client, { repo, changeId, changeSummary, p
|
|
|
603
744
|
body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
|
|
604
745
|
patch,
|
|
605
746
|
});
|
|
606
|
-
reportCandidate(result, changeId);
|
|
747
|
+
reportCandidate(result, changeId, { quiet });
|
|
607
748
|
return result;
|
|
608
749
|
} catch (e) {
|
|
609
|
-
|
|
610
|
-
|
|
750
|
+
if (!quiet) {
|
|
751
|
+
console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
|
|
752
|
+
console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
|
|
753
|
+
}
|
|
611
754
|
return null;
|
|
612
755
|
}
|
|
613
756
|
}
|
|
614
757
|
|
|
615
758
|
/** Print the candidate_open result: the PR the approver reviews, or the MCP's own
|
|
616
|
-
* refusal message when it couldn't open/update one. */
|
|
617
|
-
function reportCandidate(result, changeId) {
|
|
759
|
+
* refusal message when it couldn't open/update one. `quiet` (--json) suppresses it. */
|
|
760
|
+
function reportCandidate(result, changeId, { quiet = false } = {}) {
|
|
761
|
+
if (quiet) return;
|
|
618
762
|
if (result && typeof result.prNumber === "number") {
|
|
619
763
|
console.log(`\n ✓ candidate ${result.changeId || changeId} — PR #${result.prNumber} (${result.state || "open"})`);
|
|
620
764
|
if (result.url) console.log(` ${result.url}`);
|
|
@@ -624,12 +768,75 @@ function reportCandidate(result, changeId) {
|
|
|
624
768
|
console.log(` ~ PR-backed candidate not opened: ${msg}`);
|
|
625
769
|
}
|
|
626
770
|
|
|
771
|
+
/**
|
|
772
|
+
* Build the `--json` result object (P2 item 14): candidate id, PR, head SHA,
|
|
773
|
+
* shareable preview URL, and reconcile/compliance evidence — so an
|
|
774
|
+
* LLM/automation caller can consume structured data instead of scraping
|
|
775
|
+
* human-readable stdout. `ok` mirrors the process exit code (0 ⇒ true) so a
|
|
776
|
+
* caller can branch on one field.
|
|
777
|
+
*
|
|
778
|
+
* Also carries the honest-dispatch triad (the "never dispatched" fix) so
|
|
779
|
+
* automation gets the SAME truth the human-readable path does, never a
|
|
780
|
+
* prettier lie: `dispatched` (was a webhook delivery ever observed for this
|
|
781
|
+
* commit?), `notDispatched` (the permanent-dead-end tag from
|
|
782
|
+
* pollPreviewStatus — re-running will not help), and `delivery` (the raw
|
|
783
|
+
* observability triad, or null when nothing was ever seen). All three
|
|
784
|
+
* default to their "nothing known yet" value when `status` is absent/older,
|
|
785
|
+
* so a caller can branch on `notDispatched` unconditionally without a
|
|
786
|
+
* presence check.
|
|
787
|
+
*
|
|
788
|
+
* `previewPrUrl` (P2 item — the immediate Vercel-style shareable link, see
|
|
789
|
+
* shareablePrUrl) is threaded through separately from `previewUrl`: the
|
|
790
|
+
* latter is the server-minted, reconcile-confirmed link (null until reconcile
|
|
791
|
+
* actually lands); the former is composed client-side the instant the
|
|
792
|
+
* candidate PR opens and may point at a preview that's still building.
|
|
793
|
+
* Defaults to null when no numeric PR number was known at result-build time.
|
|
794
|
+
* Pure — unit-tested.
|
|
795
|
+
* @param {{ ok: boolean, ref?: string|null, commit?: string|null, changeId?: string|null,
|
|
796
|
+
* candidate?: {prNumber?: number, number?: number, state?: string, url?: string}|null,
|
|
797
|
+
* status?: {status?: string, reconcile?: object|null, compliance?: object|null,
|
|
798
|
+
* previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
|
|
799
|
+
* notDispatched?: boolean, delivery?: object|null}|null,
|
|
800
|
+
* previewPrUrl?: string|null, error?: string|null, note?: string|null }} input
|
|
801
|
+
*/
|
|
802
|
+
export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null }) {
|
|
803
|
+
return {
|
|
804
|
+
ok,
|
|
805
|
+
ref,
|
|
806
|
+
commit,
|
|
807
|
+
changeId,
|
|
808
|
+
candidate: candidate
|
|
809
|
+
? { number: candidate.prNumber ?? candidate.number ?? null, state: candidate.state ?? null, url: candidate.url ?? null }
|
|
810
|
+
: null,
|
|
811
|
+
status: status?.status ?? null,
|
|
812
|
+
reconcile: status?.reconcile ?? null,
|
|
813
|
+
compliance: status?.compliance ?? null,
|
|
814
|
+
previewUrl: status?.previewUrl ?? null,
|
|
815
|
+
shipped: status?.shipped ?? null,
|
|
816
|
+
dispatched: status?.dispatched ?? null,
|
|
817
|
+
notDispatched: status?.notDispatched ?? false,
|
|
818
|
+
delivery: status?.delivery ?? null,
|
|
819
|
+
previewPrUrl,
|
|
820
|
+
...(error ? { error } : {}),
|
|
821
|
+
...(note ? { note } : {}),
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Print the `--json` result as one pretty-printed object on stdout — a no-op
|
|
826
|
+
* unless `args.json` was passed, so call sites can invoke it unconditionally. */
|
|
827
|
+
function emitJson(args, payload) {
|
|
828
|
+
if (args.json) console.log(JSON.stringify(payload, null, 2));
|
|
829
|
+
}
|
|
830
|
+
|
|
627
831
|
/**
|
|
628
832
|
* The preview flow — validate, push the preview ref, open/update the PR-backed
|
|
629
833
|
* candidate, and stream back the reconcile/compliance/preview result. Reached by
|
|
630
834
|
* `tot preview` and, as teaching aliases, `tot submit` / `tot deploy` (preview.mjs
|
|
631
835
|
* wraps this and adds the verb-teaching hints). `verb` only brands the user-facing
|
|
632
836
|
* copy (usage + the not-in-checkout error) with whatever the developer typed.
|
|
837
|
+
* `--json` (args.json) suppresses the human-readable stdout narration in favor of
|
|
838
|
+
* one structured result object at the end (see buildJsonResult) — stderr
|
|
839
|
+
* diagnostics (fail(), `~ …` progress lines) still print either way.
|
|
633
840
|
* @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
|
|
634
841
|
*/
|
|
635
842
|
export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
@@ -639,13 +846,31 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
639
846
|
console.log(renderUsage(verb));
|
|
640
847
|
return 0;
|
|
641
848
|
}
|
|
849
|
+
if (args.summary && args.summaryFile) {
|
|
850
|
+
const msg = "--summary and --summary-file are mutually exclusive";
|
|
851
|
+
console.error(fail(msg, "pass one or the other"));
|
|
852
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
853
|
+
return 2;
|
|
854
|
+
}
|
|
855
|
+
if (args.summaryFile) {
|
|
856
|
+
let raw;
|
|
857
|
+
try {
|
|
858
|
+
raw = readSummaryFileContent(args.summaryFile);
|
|
859
|
+
} catch (e) {
|
|
860
|
+
const msg = `couldn't read --summary-file ${args.summaryFile}: ${String(e?.message || e)}`;
|
|
861
|
+
console.error(fail(msg, `check the path (or pass "-" to read stdin)`));
|
|
862
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
863
|
+
return 2;
|
|
864
|
+
}
|
|
865
|
+
// Feeds --summary's body unchanged from here on (buildChangeSummary etc.) —
|
|
866
|
+
// --summary-file is purely an alternate SOURCE for the same string, per the
|
|
867
|
+
// "preserve -m/--summary unchanged" requirement.
|
|
868
|
+
args.summary = summaryFromStructuredText(raw);
|
|
869
|
+
}
|
|
642
870
|
if (ctx.mode !== "checkout") {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
"tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)",
|
|
647
|
-
),
|
|
648
|
-
);
|
|
871
|
+
const msg = `\`tot ${verb}\` runs from inside a tenant checkout`;
|
|
872
|
+
console.error(fail(msg, "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)"));
|
|
873
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
649
874
|
return 2;
|
|
650
875
|
}
|
|
651
876
|
const workspace = ctx.workspacePath;
|
|
@@ -661,12 +886,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
661
886
|
try {
|
|
662
887
|
auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
|
|
663
888
|
} catch (e) {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
"commit your content manually (git add / git commit), or re-run with --no-commit",
|
|
668
|
-
),
|
|
669
|
-
);
|
|
889
|
+
const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
|
|
890
|
+
console.error(fail(msg, "commit your content manually (git add / git commit), or re-run with --no-commit"));
|
|
891
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
670
892
|
return 1;
|
|
671
893
|
}
|
|
672
894
|
if (auto.refused) {
|
|
@@ -679,6 +901,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
679
901
|
for (const p of auto.unknown) console.error(` ✗ out of scope: ${p}`);
|
|
680
902
|
console.error(`\n \`tot ${verb}\` auto-commits only: ${[...KNOWN_CONTENT_TREES, ...KNOWN_CONTENT_FILES].join(", ")}`);
|
|
681
903
|
if (auto.known.length) console.error(` (in scope, would have been committed: ${auto.known.join(", ")})`);
|
|
904
|
+
emitJson(args, buildJsonResult({ ok: false, error: `${auto.unknown.length} change(s) outside the store content trees` }));
|
|
682
905
|
return 1;
|
|
683
906
|
}
|
|
684
907
|
if (auto.committed) {
|
|
@@ -696,6 +919,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
696
919
|
fail(`${errs.length} validation error(s)`, "fix these (below), or re-run with --skip-validate") + "\n",
|
|
697
920
|
);
|
|
698
921
|
for (const f of errs) console.error(` ✗ [${f.rule}] ${f.file} — ${f.message}`);
|
|
922
|
+
emitJson(args, buildJsonResult({ ok: false, error: `${errs.length} validation error(s)` }));
|
|
699
923
|
return 1;
|
|
700
924
|
}
|
|
701
925
|
console.error("~ validated (no errors)");
|
|
@@ -706,7 +930,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
706
930
|
try {
|
|
707
931
|
commit = git(["rev-parse", "HEAD"]).trim();
|
|
708
932
|
} catch {
|
|
709
|
-
|
|
933
|
+
const msg = "no commits here yet";
|
|
934
|
+
console.error(fail(msg, "git add <files> && git commit -m '…', then re-run"));
|
|
935
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
710
936
|
return 1;
|
|
711
937
|
}
|
|
712
938
|
const short = commit.slice(0, 9);
|
|
@@ -778,22 +1004,21 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
778
1004
|
const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
|
|
779
1005
|
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
780
1006
|
} catch (pushErr) {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
"check your commit and that the checkout's remote is reachable, then re-run",
|
|
785
|
-
),
|
|
786
|
-
);
|
|
1007
|
+
const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
|
|
1008
|
+
console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
|
|
1009
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
|
|
787
1010
|
return 1;
|
|
788
1011
|
}
|
|
789
|
-
console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
790
|
-
printChangeSummary(changeSummary);
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1012
|
+
if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
1013
|
+
printChangeSummary(changeSummary, { quiet: args.json });
|
|
1014
|
+
const note = e instanceof AuthUnavailableError
|
|
1015
|
+
? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
|
|
1016
|
+
: `couldn't reach Token of Trust for the result read-back: ${String(e?.message || e)}`;
|
|
1017
|
+
if (!args.json) {
|
|
1018
|
+
console.log(` (${note})`);
|
|
1019
|
+
console.log(` Your push is in; the preview updates once reconcile completes.`);
|
|
795
1020
|
}
|
|
796
|
-
|
|
1021
|
+
emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
|
|
797
1022
|
return 0;
|
|
798
1023
|
}
|
|
799
1024
|
|
|
@@ -830,16 +1055,13 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
830
1055
|
const { out } = await pushPreviewRef(git, mintRemote, { ref });
|
|
831
1056
|
if (out && out.trim()) console.error(redactUrl(out.trim()));
|
|
832
1057
|
} catch (e) {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
"check your commit and that the checkout's remote is reachable, then re-run",
|
|
837
|
-
),
|
|
838
|
-
);
|
|
1058
|
+
const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
|
|
1059
|
+
console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
|
|
1060
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
|
|
839
1061
|
return 1;
|
|
840
1062
|
}
|
|
841
|
-
console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
842
|
-
printChangeSummary(changeSummary);
|
|
1063
|
+
if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
1064
|
+
printChangeSummary(changeSummary, { quiet: args.json });
|
|
843
1065
|
|
|
844
1066
|
// 2b + 3. open/update the PR-backed candidate, then report reconcile +
|
|
845
1067
|
// compliance + preview URL from the MCP — reusing the session established above.
|
|
@@ -858,14 +1080,29 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
858
1080
|
// above.)
|
|
859
1081
|
const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
860
1082
|
|
|
861
|
-
let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
|
|
1083
|
+
let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
862
1084
|
|
|
863
1085
|
if (candidate && isTerminalCandidateState(candidate.state)) {
|
|
864
1086
|
const rolled = mintFreshChangeId(stableId);
|
|
865
|
-
console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
|
|
1087
|
+
if (!args.json) console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
|
|
866
1088
|
changeId = rolled;
|
|
867
1089
|
persist = true;
|
|
868
|
-
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
|
|
1090
|
+
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Immediate shareable URL (Vercel-style: "the URL exists before the build
|
|
1094
|
+
// does"). A non-terminal candidate with a real PR number means a preview
|
|
1095
|
+
// WILL be built at a deterministic route — so hand the developer that link
|
|
1096
|
+
// right now, before reconcile even starts, rather than making them wait for
|
|
1097
|
+
// the server-minted `previewUrl` (formatShareableUrlBlock) that only shows
|
|
1098
|
+
// up once reconcile actually completes. Honest framing: it's printed as
|
|
1099
|
+
// "building", never as "ready".
|
|
1100
|
+
const previewPrUrl =
|
|
1101
|
+
candidate && !isTerminalCandidateState(candidate.state) && typeof candidate.prNumber === "number"
|
|
1102
|
+
? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, candidate.prNumber)
|
|
1103
|
+
: null;
|
|
1104
|
+
if (previewPrUrl && !args.json) {
|
|
1105
|
+
console.log(`\n ▸ Your preview will appear at:\n ${previewPrUrl}\n (building — this link goes live once reconcile completes)`);
|
|
869
1106
|
}
|
|
870
1107
|
|
|
871
1108
|
// Remember the active candidate only on a real, non-terminal open (best-effort;
|
|
@@ -885,35 +1122,51 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
885
1122
|
// as live, not as a growing wall of "(1)…(8)" lines. The counter keeps
|
|
886
1123
|
// ticking on its own 90ms timer even while a single poll long-polls for
|
|
887
1124
|
// waitMs, so elapsed time is real wall-clock, not the attempt count.
|
|
1125
|
+
// --json is for automation: no interactive spinner (stays silent — the
|
|
1126
|
+
// JSON result carries the same status at the end).
|
|
1127
|
+
// Honest opening label (the incident this fixes: a job may never actually
|
|
1128
|
+
// get dispatched — see pollPreviewStatus's notDispatched short-circuit — so
|
|
1129
|
+
// the INITIAL text must not assert a reconcile job exists before one has
|
|
1130
|
+
// been observed). Once a tick confirms `s.dispatched === true` the 45s
|
|
1131
|
+
// stage text ("still reconciling…") IS truthful and is left as-is below.
|
|
888
1132
|
let phase = "reconcile";
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1133
|
+
if (!args.json) {
|
|
1134
|
+
progress = startProgress(`waiting for reconcile of ${short}…`, {
|
|
1135
|
+
stages: [{ afterMs: 45_000, text: `still reconciling ${short}… (larger changes take longer)` }],
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
892
1138
|
status = await pollPreviewStatus(client, commit, {
|
|
893
1139
|
...(args.watch ? WATCH_POLL : DEFAULT_POLL),
|
|
894
1140
|
onTick: (s) => {
|
|
895
1141
|
// Reconcile is done but we're still waiting on a ship decision (--watch):
|
|
896
1142
|
// swap the label so the single line reflects the new phase, timer resets.
|
|
897
|
-
if (s.status === "reconciled" && !s.shipped && phase !== "ship") {
|
|
1143
|
+
if (!args.json && s.status === "reconciled" && !s.shipped && phase !== "ship") {
|
|
898
1144
|
phase = "ship";
|
|
899
1145
|
progress.stop();
|
|
900
1146
|
progress = startProgress(`reconciled ${short} — waiting for a ship decision…`);
|
|
901
1147
|
}
|
|
902
1148
|
},
|
|
903
1149
|
});
|
|
904
|
-
progress
|
|
905
|
-
|
|
1150
|
+
if (progress) {
|
|
1151
|
+
progress.stop();
|
|
1152
|
+
progress = null;
|
|
1153
|
+
}
|
|
906
1154
|
}
|
|
907
|
-
|
|
1155
|
+
// --json also skips the browser auto-open (open: !args.noOpen && !args.json)
|
|
1156
|
+
// — automation doesn't want a browser popping up.
|
|
1157
|
+
reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit, ref, verb });
|
|
1158
|
+
emitJson(args, buildJsonResult({ ok: status?.status !== "failed", ref, commit, changeId, candidate, status, previewPrUrl }));
|
|
908
1159
|
return status?.status === "failed" ? 1 : 0;
|
|
909
1160
|
} catch (e) {
|
|
910
1161
|
progress?.stop();
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1162
|
+
const note = e instanceof AuthUnavailableError
|
|
1163
|
+
? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
|
|
1164
|
+
: `reconcile is running — the result read-back isn't available yet: ${String(e?.message || e)}`;
|
|
1165
|
+
if (!args.json) {
|
|
1166
|
+
console.log(` (${note})`);
|
|
1167
|
+
console.log(` Your push is in; the preview updates once reconcile completes.`);
|
|
915
1168
|
}
|
|
916
|
-
|
|
1169
|
+
emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
|
|
917
1170
|
return 0;
|
|
918
1171
|
}
|
|
919
1172
|
}
|
|
@@ -921,20 +1174,31 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
921
1174
|
/**
|
|
922
1175
|
* Normalize a `preview_status` tool response to the contract shape the CLI reports
|
|
923
1176
|
* on: { status, reconcile:{ok,errors}, compliance:{verdict,detail}, previewUrl,
|
|
924
|
-
* shipped }. A live tool always returns status
|
|
925
|
-
* else (an older MCP without the tool's flat
|
|
926
|
-
* CLI degrades visibly instead of pretending
|
|
927
|
-
* null until change_accept ships this exact commit.
|
|
1177
|
+
* shipped, delivery, dispatched }. A live tool always returns status
|
|
1178
|
+
* pending|reconciled|failed; anything else (an older MCP without the tool's flat
|
|
1179
|
+
* fields) normalizes to "unknown" so the CLI degrades visibly instead of pretending
|
|
1180
|
+
* it reconciled. `shipped` (E1b) is null until change_accept ships this exact commit.
|
|
1181
|
+
*
|
|
1182
|
+
* `delivery` is the MCP's reconcile-observability triad for the Gitea webhook that
|
|
1183
|
+
* fired on this commit ({ target, actual, evidence, drift? }) — or null when NO
|
|
1184
|
+
* webhook delivery was observed for this (tenant, commit). `dispatched` distills that
|
|
1185
|
+
* to a boolean: a `pending` status with `dispatched === false` means the reconcile
|
|
1186
|
+
* was NEVER DISPATCHED (no webhook fired — e.g. an unregistered hook, or a commit
|
|
1187
|
+
* read under a different tenant scope than it was pushed to), which is a permanent
|
|
1188
|
+
* dead-end the CLI must not report as "still reconciling". Pure — unit-tested.
|
|
928
1189
|
*/
|
|
929
1190
|
export function normalizePreviewStatus(r) {
|
|
930
1191
|
const status = r?.status;
|
|
931
1192
|
const known = status === "pending" || status === "reconciled" || status === "failed";
|
|
1193
|
+
const delivery = r?.delivery ?? null;
|
|
932
1194
|
return {
|
|
933
1195
|
status: known ? status : "unknown",
|
|
934
1196
|
reconcile: r?.reconcile ?? null,
|
|
935
1197
|
compliance: r?.compliance ?? null,
|
|
936
1198
|
previewUrl: r?.previewUrl ?? null,
|
|
937
1199
|
shipped: r?.shipped ?? null,
|
|
1200
|
+
delivery,
|
|
1201
|
+
dispatched: delivery != null,
|
|
938
1202
|
raw: r,
|
|
939
1203
|
};
|
|
940
1204
|
}
|
|
@@ -951,29 +1215,54 @@ export function normalizePreviewStatus(r) {
|
|
|
951
1215
|
* the pre-E2 fixed-interval poll — no version check needed, the fallback is
|
|
952
1216
|
* automatic. Stops as soon as status resolves to "failed"/"unknown" (a failed
|
|
953
1217
|
* reconcile can't ship), or resolves to "reconciled" AND (not untilShipped, or
|
|
954
|
-
* already shipped).
|
|
1218
|
+
* already shipped).
|
|
1219
|
+
*
|
|
1220
|
+
* NEVER-DISPATCHED short-circuit (the "still reconciling forever" fix): a `pending`
|
|
1221
|
+
* status with NO delivery ever observed for this commit means no reconcile job was
|
|
1222
|
+
* ever dispatched (unregistered webhook, or a tenant-scope mismatch on the read) —
|
|
1223
|
+
* it will never resolve. Rather than walk the whole (~8 min under --watch) budget
|
|
1224
|
+
* lying about progress, once we're past a short startup grace (`notDispatchedGraceMs`
|
|
1225
|
+
* — long enough for a real delivery record to land after the push) with the delivery
|
|
1226
|
+
* still absent, we stop and return the honest state tagged `notDispatched: true`. If
|
|
1227
|
+
* a delivery IS seen we keep polling as before (dispatched, just slow), and a plain
|
|
1228
|
+
* timeout while still pending is tagged `notDispatched` only when a delivery was never
|
|
1229
|
+
* observed. Injectable delay/attempts/waitMs/grace + a `now` clock for tests.
|
|
955
1230
|
* @param {{callTool:Function}} client
|
|
956
1231
|
* @param {string} commit
|
|
957
|
-
* @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
|
|
1232
|
+
* @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
|
|
1233
|
+
* notDispatchedGraceMs?: number, now?: () => number, onTick?: (s:object,i:number)=>void }} [opts]
|
|
958
1234
|
*/
|
|
959
1235
|
export async function pollPreviewStatus(
|
|
960
1236
|
client,
|
|
961
1237
|
commit,
|
|
962
|
-
{ attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, onTick } = {},
|
|
1238
|
+
{ attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, notDispatchedGraceMs = 15_000, now = Date.now, onTick } = {},
|
|
963
1239
|
) {
|
|
964
1240
|
let last = null;
|
|
1241
|
+
let everDispatched = false;
|
|
1242
|
+
const pollStart = now();
|
|
965
1243
|
for (let i = 0; i < attempts; i++) {
|
|
966
|
-
const startedAt =
|
|
1244
|
+
const startedAt = now();
|
|
967
1245
|
const args = waitMs ? { commit, waitMs } : { commit };
|
|
968
1246
|
last = normalizePreviewStatus(await client.callTool("preview_status", args));
|
|
1247
|
+
if (last.dispatched) everDispatched = true;
|
|
1248
|
+
last.everDispatched = everDispatched;
|
|
969
1249
|
if (onTick) onTick(last, i);
|
|
1250
|
+
// Never-dispatched dead-end: still pending, no delivery has EVER been observed
|
|
1251
|
+
// for this commit, and we're past the startup grace — the reconcile will never
|
|
1252
|
+
// arrive. Return honestly instead of continuing to show "still reconciling".
|
|
1253
|
+
if (last.status === "pending" && !everDispatched && now() - pollStart >= notDispatchedGraceMs) {
|
|
1254
|
+
return { ...last, notDispatched: true };
|
|
1255
|
+
}
|
|
970
1256
|
const stillWatchingForShip = untilShipped && last.status === "reconciled" && !last.shipped;
|
|
971
1257
|
if (last.status !== "pending" && !stillWatchingForShip) return last;
|
|
972
1258
|
if (i < attempts - 1) {
|
|
973
|
-
const remaining = delayMs - (
|
|
1259
|
+
const remaining = delayMs - (now() - startedAt);
|
|
974
1260
|
if (remaining > 0) await delay(remaining);
|
|
975
1261
|
}
|
|
976
1262
|
}
|
|
1263
|
+
// Budget exhausted. A still-pending result that never saw a delivery is a
|
|
1264
|
+
// never-dispatched dead-end (honest), not "still working".
|
|
1265
|
+
if (last) return { ...last, everDispatched, notDispatched: last.status === "pending" && !everDispatched };
|
|
977
1266
|
return last;
|
|
978
1267
|
}
|
|
979
1268
|
|
|
@@ -993,6 +1282,30 @@ function reportComplianceCheck(c) {
|
|
|
993
1282
|
if (c.hint) console.log(` → fix: ${c.hint}`);
|
|
994
1283
|
}
|
|
995
1284
|
|
|
1285
|
+
/**
|
|
1286
|
+
* The IMMEDIATE shareable preview URL (Vercel-style: "the URL exists before the
|
|
1287
|
+
* build does") — composed client-side, deterministically, from the tenant + PR
|
|
1288
|
+
* number the candidate_open call just returned, so a developer gets a link to
|
|
1289
|
+
* paste to a reviewer the INSTANT the candidate opens, not minutes later once
|
|
1290
|
+
* reconcile finishes and the MCP mints `previewUrl` server-side (that's
|
|
1291
|
+
* formatShareableUrlBlock's job, above — the two are deliberately redundant:
|
|
1292
|
+
* this one is available immediately but "building", that one is authoritative
|
|
1293
|
+
* once reconcile actually lands). Same route shape as the server-minted one
|
|
1294
|
+
* (`/preview/<tenant>/pr/<N>`) by construction — see
|
|
1295
|
+
* docs/architecture/preview-candidate-workflow.md — so the link doesn't change
|
|
1296
|
+
* out from under the reviewer once the build completes; it just starts
|
|
1297
|
+
* resolving.
|
|
1298
|
+
* Trims a trailing slash off `base` so a `TOT_STOREFRONT_URL` set WITH or
|
|
1299
|
+
* without one composes identically. Pure — unit-tested.
|
|
1300
|
+
* @param {string} base storefront origin, e.g. https://storefront.tokenoftrust.store
|
|
1301
|
+
* @param {string} tenant
|
|
1302
|
+
* @param {number} prNumber
|
|
1303
|
+
* @returns {string}
|
|
1304
|
+
*/
|
|
1305
|
+
export function shareablePrUrl(base, tenant, prNumber) {
|
|
1306
|
+
return `${String(base).replace(/\/+$/, "")}/preview/${tenant}/pr/${prNumber}`;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
996
1309
|
/**
|
|
997
1310
|
* Build the printed lines for the headline "share this with your reviewer" block —
|
|
998
1311
|
* the whole point of U14: on a successful preview, the SHAREABLE deep link
|
|
@@ -1025,41 +1338,91 @@ export function formatShareableUrlBlock(s, tenant) {
|
|
|
1025
1338
|
return [];
|
|
1026
1339
|
}
|
|
1027
1340
|
|
|
1341
|
+
/**
|
|
1342
|
+
* The honest "no reconcile job was dispatched" block — printed when a preview stays
|
|
1343
|
+
* `pending` with no webhook delivery ever observed for the commit (pollPreviewStatus
|
|
1344
|
+
* tagged it `notDispatched`). This replaces the old "reconcile still running — check
|
|
1345
|
+
* back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
|
|
1346
|
+
* what actually happened and what to do, and never recommend another submit. Pure —
|
|
1347
|
+
* unit-tested. `verb` brands the copy with whatever the developer typed.
|
|
1348
|
+
* @param {{ commit?: string|null, ref?: string|null }} ctx
|
|
1349
|
+
* @param {string} tenant @param {string} [verb]
|
|
1350
|
+
* @returns {string[]}
|
|
1351
|
+
*/
|
|
1352
|
+
export function formatNotDispatchedBlock({ commit = null, ref = null } = {}, tenant, verb = "preview") {
|
|
1353
|
+
const short = commit ? commit.slice(0, 9) : "(unknown commit)";
|
|
1354
|
+
return [
|
|
1355
|
+
`\n ⚠ No reconcile was dispatched for ${short} on ${tenant}.`,
|
|
1356
|
+
` Your push landed${ref ? ` on ${ref}` : ""}, but nothing picked it up to build a preview —`,
|
|
1357
|
+
` re-running \`tot ${verb}\` will NOT change that. This usually means one of:`,
|
|
1358
|
+
` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
|
|
1359
|
+
` • your session is scoped to a different store than the one you pushed.`,
|
|
1360
|
+
` Next:`,
|
|
1361
|
+
` • \`tot grants\` — confirm ${tenant} is active for you;`,
|
|
1362
|
+
` • check the preview dashboard for ${tenant} (it will read "Last reconcile: never" until a job runs);`,
|
|
1363
|
+
` • if it stays "never", share this with support: commit ${short}, tenant ${tenant}${ref ? `, ref ${ref}` : ""}.`,
|
|
1364
|
+
];
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1028
1367
|
/**
|
|
1029
1368
|
* Print the reconcile/compliance/preview result and, on a clean reconcile with a
|
|
1030
|
-
* preview URL, open it in the browser (unless opts.open === false).
|
|
1369
|
+
* preview URL, open it in the browser (unless opts.open === false). `quiet`
|
|
1370
|
+
* (--json) suppresses ALL printing here — the browser open still runs unless
|
|
1371
|
+
* the caller also passes `open: false` (run() passes `open: false` under
|
|
1372
|
+
* --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
|
|
1373
|
+
* feed the honest never-dispatched block.
|
|
1031
1374
|
*/
|
|
1032
|
-
function reportStatus(s, tenant, { open = true } = {}) {
|
|
1375
|
+
function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview" } = {}) {
|
|
1033
1376
|
if (!s || s.status === "unknown") {
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
`
|
|
1037
|
-
|
|
1377
|
+
if (!quiet) {
|
|
1378
|
+
console.log(
|
|
1379
|
+
` (this MCP doesn't return the per-commit reconcile result yet — your push is in;\n` +
|
|
1380
|
+
` the preview updates once reconcile runs. Check the preview dashboard.)`,
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1038
1383
|
return;
|
|
1039
1384
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1385
|
+
// Never-dispatched dead-end — the honest replacement for false "still reconciling".
|
|
1386
|
+
if (s.notDispatched) {
|
|
1387
|
+
if (!quiet) for (const line of formatNotDispatchedBlock({ commit, ref }, tenant, verb)) console.log(line);
|
|
1042
1388
|
return;
|
|
1043
1389
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1390
|
+
if (s.status === "pending") {
|
|
1391
|
+
if (!quiet) {
|
|
1392
|
+
// Dispatched but not yet reported (real slow reconcile) vs. no job seen yet on
|
|
1393
|
+
// a --no-wait snapshot — say which, and never claim progress we can't see.
|
|
1394
|
+
if (s.dispatched === false) {
|
|
1395
|
+
console.log(` no reconcile job seen yet for ${tenant} — if it doesn't appear shortly, run \`tot grants\` / check the dashboard.`);
|
|
1396
|
+
} else {
|
|
1397
|
+
console.log(` reconcile still running for ${tenant} — check back shortly (re-run \`tot submit --no-wait\`).`);
|
|
1398
|
+
if (s.delivery?.drift) {
|
|
1399
|
+
console.log(` ⚠ a reconcile report exists for a DIFFERENT commit than you pushed — possible tenant-scope mismatch (\`tot grants\` to check your active store).`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1050
1402
|
}
|
|
1403
|
+
return;
|
|
1051
1404
|
}
|
|
1052
|
-
if (
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1405
|
+
if (!quiet) {
|
|
1406
|
+
const rc = s.reconcile;
|
|
1407
|
+
if (rc) {
|
|
1408
|
+
if (rc.ok) console.log(` ✓ reconcile ok`);
|
|
1409
|
+
else {
|
|
1410
|
+
console.log(` ✗ reconcile failed:`);
|
|
1411
|
+
for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
if (s.compliance?.verdict) {
|
|
1415
|
+
console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
|
|
1416
|
+
for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
|
|
1417
|
+
}
|
|
1418
|
+
if (s.shipped) {
|
|
1419
|
+
console.log(`\n ✓ shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
|
|
1420
|
+
} else if (s.status === "reconciled") {
|
|
1421
|
+
console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
|
|
1422
|
+
}
|
|
1423
|
+
for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
|
|
1060
1424
|
}
|
|
1061
|
-
for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
|
|
1062
1425
|
if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
|
|
1063
|
-
console.log(" (opened in your browser)");
|
|
1426
|
+
if (!quiet) console.log(" (opened in your browser)");
|
|
1064
1427
|
}
|
|
1065
1428
|
}
|