@tokenoftrust/cli 1.4.0-rc.15 → 1.4.0-rc.17
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 +461 -95
- 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,33 +744,102 @@ 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
|
-
|
|
759
|
+
* refusal message when it couldn't open/update one. `quiet` (--json) suppresses it.
|
|
760
|
+
* Deliberately does NOT print `result.url` — that's the INTERNAL forge (Gitea) PR
|
|
761
|
+
* link, plumbing a developer never needs to see (DZ, 2026-08-15); the product
|
|
762
|
+
* surface is the shareable /preview/<tenant>/pr/<N> URL printed right after
|
|
763
|
+
* (shareablePrUrl). The forge URL still rides the --json payload for tooling. */
|
|
764
|
+
function reportCandidate(result, changeId, { quiet = false } = {}) {
|
|
765
|
+
if (quiet) return;
|
|
618
766
|
if (result && typeof result.prNumber === "number") {
|
|
619
767
|
console.log(`\n ✓ candidate ${result.changeId || changeId} — PR #${result.prNumber} (${result.state || "open"})`);
|
|
620
|
-
if (result.url) console.log(` ${result.url}`);
|
|
621
768
|
return;
|
|
622
769
|
}
|
|
623
770
|
const msg = result?.message || (result?.raw && String(result.raw)) || JSON.stringify(result ?? null);
|
|
624
771
|
console.log(` ~ PR-backed candidate not opened: ${msg}`);
|
|
625
772
|
}
|
|
626
773
|
|
|
774
|
+
/**
|
|
775
|
+
* Build the `--json` result object (P2 item 14): candidate id, PR, head SHA,
|
|
776
|
+
* shareable preview URL, and reconcile/compliance evidence — so an
|
|
777
|
+
* LLM/automation caller can consume structured data instead of scraping
|
|
778
|
+
* human-readable stdout. `ok` mirrors the process exit code (0 ⇒ true) so a
|
|
779
|
+
* caller can branch on one field.
|
|
780
|
+
*
|
|
781
|
+
* Also carries the honest-dispatch triad (the "never dispatched" fix) so
|
|
782
|
+
* automation gets the SAME truth the human-readable path does, never a
|
|
783
|
+
* prettier lie: `dispatched` (was a webhook delivery ever observed for this
|
|
784
|
+
* commit?), `notDispatched` (the permanent-dead-end tag from
|
|
785
|
+
* pollPreviewStatus — re-running will not help), and `delivery` (the raw
|
|
786
|
+
* observability triad, or null when nothing was ever seen). All three
|
|
787
|
+
* default to their "nothing known yet" value when `status` is absent/older,
|
|
788
|
+
* so a caller can branch on `notDispatched` unconditionally without a
|
|
789
|
+
* presence check.
|
|
790
|
+
*
|
|
791
|
+
* `previewPrUrl` (P2 item — the immediate Vercel-style shareable link, see
|
|
792
|
+
* shareablePrUrl) is threaded through separately from `previewUrl`: the
|
|
793
|
+
* latter is the server-minted, reconcile-confirmed link (null until reconcile
|
|
794
|
+
* actually lands); the former is composed client-side the instant the
|
|
795
|
+
* candidate PR opens and may point at a preview that's still building.
|
|
796
|
+
* Defaults to null when no numeric PR number was known at result-build time.
|
|
797
|
+
* Pure — unit-tested.
|
|
798
|
+
* @param {{ ok: boolean, ref?: string|null, commit?: string|null, changeId?: string|null,
|
|
799
|
+
* candidate?: {prNumber?: number, number?: number, state?: string, url?: string}|null,
|
|
800
|
+
* status?: {status?: string, reconcile?: object|null, compliance?: object|null,
|
|
801
|
+
* previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
|
|
802
|
+
* notDispatched?: boolean, delivery?: object|null}|null,
|
|
803
|
+
* previewPrUrl?: string|null, error?: string|null, note?: string|null }} input
|
|
804
|
+
*/
|
|
805
|
+
export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null }) {
|
|
806
|
+
return {
|
|
807
|
+
ok,
|
|
808
|
+
ref,
|
|
809
|
+
commit,
|
|
810
|
+
changeId,
|
|
811
|
+
candidate: candidate
|
|
812
|
+
? { number: candidate.prNumber ?? candidate.number ?? null, state: candidate.state ?? null, url: candidate.url ?? null }
|
|
813
|
+
: null,
|
|
814
|
+
status: status?.status ?? null,
|
|
815
|
+
reconcile: status?.reconcile ?? null,
|
|
816
|
+
compliance: status?.compliance ?? null,
|
|
817
|
+
previewUrl: status?.previewUrl ?? null,
|
|
818
|
+
shipped: status?.shipped ?? null,
|
|
819
|
+
dispatched: status?.dispatched ?? null,
|
|
820
|
+
notDispatched: status?.notDispatched ?? false,
|
|
821
|
+
delivery: status?.delivery ?? null,
|
|
822
|
+
previewPrUrl,
|
|
823
|
+
...(error ? { error } : {}),
|
|
824
|
+
...(note ? { note } : {}),
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Print the `--json` result as one pretty-printed object on stdout — a no-op
|
|
829
|
+
* unless `args.json` was passed, so call sites can invoke it unconditionally. */
|
|
830
|
+
function emitJson(args, payload) {
|
|
831
|
+
if (args.json) console.log(JSON.stringify(payload, null, 2));
|
|
832
|
+
}
|
|
833
|
+
|
|
627
834
|
/**
|
|
628
835
|
* The preview flow — validate, push the preview ref, open/update the PR-backed
|
|
629
836
|
* candidate, and stream back the reconcile/compliance/preview result. Reached by
|
|
630
837
|
* `tot preview` and, as teaching aliases, `tot submit` / `tot deploy` (preview.mjs
|
|
631
838
|
* wraps this and adds the verb-teaching hints). `verb` only brands the user-facing
|
|
632
839
|
* copy (usage + the not-in-checkout error) with whatever the developer typed.
|
|
840
|
+
* `--json` (args.json) suppresses the human-readable stdout narration in favor of
|
|
841
|
+
* one structured result object at the end (see buildJsonResult) — stderr
|
|
842
|
+
* diagnostics (fail(), `~ …` progress lines) still print either way.
|
|
633
843
|
* @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
|
|
634
844
|
*/
|
|
635
845
|
export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
@@ -639,13 +849,31 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
639
849
|
console.log(renderUsage(verb));
|
|
640
850
|
return 0;
|
|
641
851
|
}
|
|
852
|
+
if (args.summary && args.summaryFile) {
|
|
853
|
+
const msg = "--summary and --summary-file are mutually exclusive";
|
|
854
|
+
console.error(fail(msg, "pass one or the other"));
|
|
855
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
856
|
+
return 2;
|
|
857
|
+
}
|
|
858
|
+
if (args.summaryFile) {
|
|
859
|
+
let raw;
|
|
860
|
+
try {
|
|
861
|
+
raw = readSummaryFileContent(args.summaryFile);
|
|
862
|
+
} catch (e) {
|
|
863
|
+
const msg = `couldn't read --summary-file ${args.summaryFile}: ${String(e?.message || e)}`;
|
|
864
|
+
console.error(fail(msg, `check the path (or pass "-" to read stdin)`));
|
|
865
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
866
|
+
return 2;
|
|
867
|
+
}
|
|
868
|
+
// Feeds --summary's body unchanged from here on (buildChangeSummary etc.) —
|
|
869
|
+
// --summary-file is purely an alternate SOURCE for the same string, per the
|
|
870
|
+
// "preserve -m/--summary unchanged" requirement.
|
|
871
|
+
args.summary = summaryFromStructuredText(raw);
|
|
872
|
+
}
|
|
642
873
|
if (ctx.mode !== "checkout") {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
"tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)",
|
|
647
|
-
),
|
|
648
|
-
);
|
|
874
|
+
const msg = `\`tot ${verb}\` runs from inside a tenant checkout`;
|
|
875
|
+
console.error(fail(msg, "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)"));
|
|
876
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
649
877
|
return 2;
|
|
650
878
|
}
|
|
651
879
|
const workspace = ctx.workspacePath;
|
|
@@ -661,12 +889,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
661
889
|
try {
|
|
662
890
|
auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
|
|
663
891
|
} catch (e) {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
"commit your content manually (git add / git commit), or re-run with --no-commit",
|
|
668
|
-
),
|
|
669
|
-
);
|
|
892
|
+
const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
|
|
893
|
+
console.error(fail(msg, "commit your content manually (git add / git commit), or re-run with --no-commit"));
|
|
894
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
670
895
|
return 1;
|
|
671
896
|
}
|
|
672
897
|
if (auto.refused) {
|
|
@@ -679,6 +904,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
679
904
|
for (const p of auto.unknown) console.error(` ✗ out of scope: ${p}`);
|
|
680
905
|
console.error(`\n \`tot ${verb}\` auto-commits only: ${[...KNOWN_CONTENT_TREES, ...KNOWN_CONTENT_FILES].join(", ")}`);
|
|
681
906
|
if (auto.known.length) console.error(` (in scope, would have been committed: ${auto.known.join(", ")})`);
|
|
907
|
+
emitJson(args, buildJsonResult({ ok: false, error: `${auto.unknown.length} change(s) outside the store content trees` }));
|
|
682
908
|
return 1;
|
|
683
909
|
}
|
|
684
910
|
if (auto.committed) {
|
|
@@ -696,6 +922,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
696
922
|
fail(`${errs.length} validation error(s)`, "fix these (below), or re-run with --skip-validate") + "\n",
|
|
697
923
|
);
|
|
698
924
|
for (const f of errs) console.error(` ✗ [${f.rule}] ${f.file} — ${f.message}`);
|
|
925
|
+
emitJson(args, buildJsonResult({ ok: false, error: `${errs.length} validation error(s)` }));
|
|
699
926
|
return 1;
|
|
700
927
|
}
|
|
701
928
|
console.error("~ validated (no errors)");
|
|
@@ -706,7 +933,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
706
933
|
try {
|
|
707
934
|
commit = git(["rev-parse", "HEAD"]).trim();
|
|
708
935
|
} catch {
|
|
709
|
-
|
|
936
|
+
const msg = "no commits here yet";
|
|
937
|
+
console.error(fail(msg, "git add <files> && git commit -m '…', then re-run"));
|
|
938
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
710
939
|
return 1;
|
|
711
940
|
}
|
|
712
941
|
const short = commit.slice(0, 9);
|
|
@@ -778,22 +1007,21 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
778
1007
|
const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
|
|
779
1008
|
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
780
1009
|
} catch (pushErr) {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
"check your commit and that the checkout's remote is reachable, then re-run",
|
|
785
|
-
),
|
|
786
|
-
);
|
|
1010
|
+
const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
|
|
1011
|
+
console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
|
|
1012
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
|
|
787
1013
|
return 1;
|
|
788
1014
|
}
|
|
789
|
-
console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
790
|
-
printChangeSummary(changeSummary);
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1015
|
+
if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
1016
|
+
printChangeSummary(changeSummary, { quiet: args.json });
|
|
1017
|
+
const note = e instanceof AuthUnavailableError
|
|
1018
|
+
? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
|
|
1019
|
+
: `couldn't reach Token of Trust for the result read-back: ${String(e?.message || e)}`;
|
|
1020
|
+
if (!args.json) {
|
|
1021
|
+
console.log(` (${note})`);
|
|
1022
|
+
console.log(` Your push is in; the preview updates once reconcile completes.`);
|
|
795
1023
|
}
|
|
796
|
-
|
|
1024
|
+
emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
|
|
797
1025
|
return 0;
|
|
798
1026
|
}
|
|
799
1027
|
|
|
@@ -830,16 +1058,13 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
830
1058
|
const { out } = await pushPreviewRef(git, mintRemote, { ref });
|
|
831
1059
|
if (out && out.trim()) console.error(redactUrl(out.trim()));
|
|
832
1060
|
} catch (e) {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
"check your commit and that the checkout's remote is reachable, then re-run",
|
|
837
|
-
),
|
|
838
|
-
);
|
|
1061
|
+
const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
|
|
1062
|
+
console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
|
|
1063
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
|
|
839
1064
|
return 1;
|
|
840
1065
|
}
|
|
841
|
-
console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
842
|
-
printChangeSummary(changeSummary);
|
|
1066
|
+
if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
1067
|
+
printChangeSummary(changeSummary, { quiet: args.json });
|
|
843
1068
|
|
|
844
1069
|
// 2b + 3. open/update the PR-backed candidate, then report reconcile +
|
|
845
1070
|
// compliance + preview URL from the MCP — reusing the session established above.
|
|
@@ -858,14 +1083,29 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
858
1083
|
// above.)
|
|
859
1084
|
const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
860
1085
|
|
|
861
|
-
let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
|
|
1086
|
+
let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
862
1087
|
|
|
863
1088
|
if (candidate && isTerminalCandidateState(candidate.state)) {
|
|
864
1089
|
const rolled = mintFreshChangeId(stableId);
|
|
865
|
-
console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
|
|
1090
|
+
if (!args.json) console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
|
|
866
1091
|
changeId = rolled;
|
|
867
1092
|
persist = true;
|
|
868
|
-
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
|
|
1093
|
+
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Immediate shareable URL (Vercel-style: "the URL exists before the build
|
|
1097
|
+
// does"). A non-terminal candidate with a real PR number means a preview
|
|
1098
|
+
// WILL be built at a deterministic route — so hand the developer that link
|
|
1099
|
+
// right now, before reconcile even starts, rather than making them wait for
|
|
1100
|
+
// the server-minted `previewUrl` (formatShareableUrlBlock) that only shows
|
|
1101
|
+
// up once reconcile actually completes. Honest framing: it's printed as
|
|
1102
|
+
// "building", never as "ready".
|
|
1103
|
+
const previewPrUrl =
|
|
1104
|
+
candidate && !isTerminalCandidateState(candidate.state) && typeof candidate.prNumber === "number"
|
|
1105
|
+
? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, candidate.prNumber)
|
|
1106
|
+
: null;
|
|
1107
|
+
if (previewPrUrl && !args.json) {
|
|
1108
|
+
console.log(`\n ▸ Your preview will appear at:\n ${previewPrUrl}\n (building — this link goes live once reconcile completes)`);
|
|
869
1109
|
}
|
|
870
1110
|
|
|
871
1111
|
// Remember the active candidate only on a real, non-terminal open (best-effort;
|
|
@@ -885,35 +1125,51 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
885
1125
|
// as live, not as a growing wall of "(1)…(8)" lines. The counter keeps
|
|
886
1126
|
// ticking on its own 90ms timer even while a single poll long-polls for
|
|
887
1127
|
// waitMs, so elapsed time is real wall-clock, not the attempt count.
|
|
1128
|
+
// --json is for automation: no interactive spinner (stays silent — the
|
|
1129
|
+
// JSON result carries the same status at the end).
|
|
1130
|
+
// Honest opening label (the incident this fixes: a job may never actually
|
|
1131
|
+
// get dispatched — see pollPreviewStatus's notDispatched short-circuit — so
|
|
1132
|
+
// the INITIAL text must not assert a reconcile job exists before one has
|
|
1133
|
+
// been observed). Once a tick confirms `s.dispatched === true` the 45s
|
|
1134
|
+
// stage text ("still reconciling…") IS truthful and is left as-is below.
|
|
888
1135
|
let phase = "reconcile";
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1136
|
+
if (!args.json) {
|
|
1137
|
+
progress = startProgress(`waiting for reconcile of ${short}…`, {
|
|
1138
|
+
stages: [{ afterMs: 45_000, text: `still reconciling ${short}… (larger changes take longer)` }],
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
892
1141
|
status = await pollPreviewStatus(client, commit, {
|
|
893
1142
|
...(args.watch ? WATCH_POLL : DEFAULT_POLL),
|
|
894
1143
|
onTick: (s) => {
|
|
895
1144
|
// Reconcile is done but we're still waiting on a ship decision (--watch):
|
|
896
1145
|
// swap the label so the single line reflects the new phase, timer resets.
|
|
897
|
-
if (s.status === "reconciled" && !s.shipped && phase !== "ship") {
|
|
1146
|
+
if (!args.json && s.status === "reconciled" && !s.shipped && phase !== "ship") {
|
|
898
1147
|
phase = "ship";
|
|
899
1148
|
progress.stop();
|
|
900
1149
|
progress = startProgress(`reconciled ${short} — waiting for a ship decision…`);
|
|
901
1150
|
}
|
|
902
1151
|
},
|
|
903
1152
|
});
|
|
904
|
-
progress
|
|
905
|
-
|
|
1153
|
+
if (progress) {
|
|
1154
|
+
progress.stop();
|
|
1155
|
+
progress = null;
|
|
1156
|
+
}
|
|
906
1157
|
}
|
|
907
|
-
|
|
1158
|
+
// --json also skips the browser auto-open (open: !args.noOpen && !args.json)
|
|
1159
|
+
// — automation doesn't want a browser popping up.
|
|
1160
|
+
reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit, ref, verb });
|
|
1161
|
+
emitJson(args, buildJsonResult({ ok: status?.status !== "failed", ref, commit, changeId, candidate, status, previewPrUrl }));
|
|
908
1162
|
return status?.status === "failed" ? 1 : 0;
|
|
909
1163
|
} catch (e) {
|
|
910
1164
|
progress?.stop();
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1165
|
+
const note = e instanceof AuthUnavailableError
|
|
1166
|
+
? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
|
|
1167
|
+
: `reconcile is running — the result read-back isn't available yet: ${String(e?.message || e)}`;
|
|
1168
|
+
if (!args.json) {
|
|
1169
|
+
console.log(` (${note})`);
|
|
1170
|
+
console.log(` Your push is in; the preview updates once reconcile completes.`);
|
|
915
1171
|
}
|
|
916
|
-
|
|
1172
|
+
emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
|
|
917
1173
|
return 0;
|
|
918
1174
|
}
|
|
919
1175
|
}
|
|
@@ -921,20 +1177,31 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
921
1177
|
/**
|
|
922
1178
|
* Normalize a `preview_status` tool response to the contract shape the CLI reports
|
|
923
1179
|
* 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.
|
|
1180
|
+
* shipped, delivery, dispatched }. A live tool always returns status
|
|
1181
|
+
* pending|reconciled|failed; anything else (an older MCP without the tool's flat
|
|
1182
|
+
* fields) normalizes to "unknown" so the CLI degrades visibly instead of pretending
|
|
1183
|
+
* it reconciled. `shipped` (E1b) is null until change_accept ships this exact commit.
|
|
1184
|
+
*
|
|
1185
|
+
* `delivery` is the MCP's reconcile-observability triad for the Gitea webhook that
|
|
1186
|
+
* fired on this commit ({ target, actual, evidence, drift? }) — or null when NO
|
|
1187
|
+
* webhook delivery was observed for this (tenant, commit). `dispatched` distills that
|
|
1188
|
+
* to a boolean: a `pending` status with `dispatched === false` means the reconcile
|
|
1189
|
+
* was NEVER DISPATCHED (no webhook fired — e.g. an unregistered hook, or a commit
|
|
1190
|
+
* read under a different tenant scope than it was pushed to), which is a permanent
|
|
1191
|
+
* dead-end the CLI must not report as "still reconciling". Pure — unit-tested.
|
|
928
1192
|
*/
|
|
929
1193
|
export function normalizePreviewStatus(r) {
|
|
930
1194
|
const status = r?.status;
|
|
931
1195
|
const known = status === "pending" || status === "reconciled" || status === "failed";
|
|
1196
|
+
const delivery = r?.delivery ?? null;
|
|
932
1197
|
return {
|
|
933
1198
|
status: known ? status : "unknown",
|
|
934
1199
|
reconcile: r?.reconcile ?? null,
|
|
935
1200
|
compliance: r?.compliance ?? null,
|
|
936
1201
|
previewUrl: r?.previewUrl ?? null,
|
|
937
1202
|
shipped: r?.shipped ?? null,
|
|
1203
|
+
delivery,
|
|
1204
|
+
dispatched: delivery != null,
|
|
938
1205
|
raw: r,
|
|
939
1206
|
};
|
|
940
1207
|
}
|
|
@@ -951,29 +1218,54 @@ export function normalizePreviewStatus(r) {
|
|
|
951
1218
|
* the pre-E2 fixed-interval poll — no version check needed, the fallback is
|
|
952
1219
|
* automatic. Stops as soon as status resolves to "failed"/"unknown" (a failed
|
|
953
1220
|
* reconcile can't ship), or resolves to "reconciled" AND (not untilShipped, or
|
|
954
|
-
* already shipped).
|
|
1221
|
+
* already shipped).
|
|
1222
|
+
*
|
|
1223
|
+
* NEVER-DISPATCHED short-circuit (the "still reconciling forever" fix): a `pending`
|
|
1224
|
+
* status with NO delivery ever observed for this commit means no reconcile job was
|
|
1225
|
+
* ever dispatched (unregistered webhook, or a tenant-scope mismatch on the read) —
|
|
1226
|
+
* it will never resolve. Rather than walk the whole (~8 min under --watch) budget
|
|
1227
|
+
* lying about progress, once we're past a short startup grace (`notDispatchedGraceMs`
|
|
1228
|
+
* — long enough for a real delivery record to land after the push) with the delivery
|
|
1229
|
+
* still absent, we stop and return the honest state tagged `notDispatched: true`. If
|
|
1230
|
+
* a delivery IS seen we keep polling as before (dispatched, just slow), and a plain
|
|
1231
|
+
* timeout while still pending is tagged `notDispatched` only when a delivery was never
|
|
1232
|
+
* observed. Injectable delay/attempts/waitMs/grace + a `now` clock for tests.
|
|
955
1233
|
* @param {{callTool:Function}} client
|
|
956
1234
|
* @param {string} commit
|
|
957
|
-
* @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
|
|
1235
|
+
* @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
|
|
1236
|
+
* notDispatchedGraceMs?: number, now?: () => number, onTick?: (s:object,i:number)=>void }} [opts]
|
|
958
1237
|
*/
|
|
959
1238
|
export async function pollPreviewStatus(
|
|
960
1239
|
client,
|
|
961
1240
|
commit,
|
|
962
|
-
{ attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, onTick } = {},
|
|
1241
|
+
{ attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, notDispatchedGraceMs = 15_000, now = Date.now, onTick } = {},
|
|
963
1242
|
) {
|
|
964
1243
|
let last = null;
|
|
1244
|
+
let everDispatched = false;
|
|
1245
|
+
const pollStart = now();
|
|
965
1246
|
for (let i = 0; i < attempts; i++) {
|
|
966
|
-
const startedAt =
|
|
1247
|
+
const startedAt = now();
|
|
967
1248
|
const args = waitMs ? { commit, waitMs } : { commit };
|
|
968
1249
|
last = normalizePreviewStatus(await client.callTool("preview_status", args));
|
|
1250
|
+
if (last.dispatched) everDispatched = true;
|
|
1251
|
+
last.everDispatched = everDispatched;
|
|
969
1252
|
if (onTick) onTick(last, i);
|
|
1253
|
+
// Never-dispatched dead-end: still pending, no delivery has EVER been observed
|
|
1254
|
+
// for this commit, and we're past the startup grace — the reconcile will never
|
|
1255
|
+
// arrive. Return honestly instead of continuing to show "still reconciling".
|
|
1256
|
+
if (last.status === "pending" && !everDispatched && now() - pollStart >= notDispatchedGraceMs) {
|
|
1257
|
+
return { ...last, notDispatched: true };
|
|
1258
|
+
}
|
|
970
1259
|
const stillWatchingForShip = untilShipped && last.status === "reconciled" && !last.shipped;
|
|
971
1260
|
if (last.status !== "pending" && !stillWatchingForShip) return last;
|
|
972
1261
|
if (i < attempts - 1) {
|
|
973
|
-
const remaining = delayMs - (
|
|
1262
|
+
const remaining = delayMs - (now() - startedAt);
|
|
974
1263
|
if (remaining > 0) await delay(remaining);
|
|
975
1264
|
}
|
|
976
1265
|
}
|
|
1266
|
+
// Budget exhausted. A still-pending result that never saw a delivery is a
|
|
1267
|
+
// never-dispatched dead-end (honest), not "still working".
|
|
1268
|
+
if (last) return { ...last, everDispatched, notDispatched: last.status === "pending" && !everDispatched };
|
|
977
1269
|
return last;
|
|
978
1270
|
}
|
|
979
1271
|
|
|
@@ -993,6 +1285,30 @@ function reportComplianceCheck(c) {
|
|
|
993
1285
|
if (c.hint) console.log(` → fix: ${c.hint}`);
|
|
994
1286
|
}
|
|
995
1287
|
|
|
1288
|
+
/**
|
|
1289
|
+
* The IMMEDIATE shareable preview URL (Vercel-style: "the URL exists before the
|
|
1290
|
+
* build does") — composed client-side, deterministically, from the tenant + PR
|
|
1291
|
+
* number the candidate_open call just returned, so a developer gets a link to
|
|
1292
|
+
* paste to a reviewer the INSTANT the candidate opens, not minutes later once
|
|
1293
|
+
* reconcile finishes and the MCP mints `previewUrl` server-side (that's
|
|
1294
|
+
* formatShareableUrlBlock's job, above — the two are deliberately redundant:
|
|
1295
|
+
* this one is available immediately but "building", that one is authoritative
|
|
1296
|
+
* once reconcile actually lands). Same route shape as the server-minted one
|
|
1297
|
+
* (`/preview/<tenant>/pr/<N>`) by construction — see
|
|
1298
|
+
* docs/architecture/preview-candidate-workflow.md — so the link doesn't change
|
|
1299
|
+
* out from under the reviewer once the build completes; it just starts
|
|
1300
|
+
* resolving.
|
|
1301
|
+
* Trims a trailing slash off `base` so a `TOT_STOREFRONT_URL` set WITH or
|
|
1302
|
+
* without one composes identically. Pure — unit-tested.
|
|
1303
|
+
* @param {string} base storefront origin, e.g. https://storefront.tokenoftrust.store
|
|
1304
|
+
* @param {string} tenant
|
|
1305
|
+
* @param {number} prNumber
|
|
1306
|
+
* @returns {string}
|
|
1307
|
+
*/
|
|
1308
|
+
export function shareablePrUrl(base, tenant, prNumber) {
|
|
1309
|
+
return `${String(base).replace(/\/+$/, "")}/preview/${tenant}/pr/${prNumber}`;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
996
1312
|
/**
|
|
997
1313
|
* Build the printed lines for the headline "share this with your reviewer" block —
|
|
998
1314
|
* the whole point of U14: on a successful preview, the SHAREABLE deep link
|
|
@@ -1025,41 +1341,91 @@ export function formatShareableUrlBlock(s, tenant) {
|
|
|
1025
1341
|
return [];
|
|
1026
1342
|
}
|
|
1027
1343
|
|
|
1344
|
+
/**
|
|
1345
|
+
* The honest "no reconcile job was dispatched" block — printed when a preview stays
|
|
1346
|
+
* `pending` with no webhook delivery ever observed for the commit (pollPreviewStatus
|
|
1347
|
+
* tagged it `notDispatched`). This replaces the old "reconcile still running — check
|
|
1348
|
+
* back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
|
|
1349
|
+
* what actually happened and what to do, and never recommend another submit. Pure —
|
|
1350
|
+
* unit-tested. `verb` brands the copy with whatever the developer typed.
|
|
1351
|
+
* @param {{ commit?: string|null, ref?: string|null }} ctx
|
|
1352
|
+
* @param {string} tenant @param {string} [verb]
|
|
1353
|
+
* @returns {string[]}
|
|
1354
|
+
*/
|
|
1355
|
+
export function formatNotDispatchedBlock({ commit = null, ref = null } = {}, tenant, verb = "preview") {
|
|
1356
|
+
const short = commit ? commit.slice(0, 9) : "(unknown commit)";
|
|
1357
|
+
return [
|
|
1358
|
+
`\n ⚠ No reconcile was dispatched for ${short} on ${tenant}.`,
|
|
1359
|
+
` Your push landed${ref ? ` on ${ref}` : ""}, but nothing picked it up to build a preview —`,
|
|
1360
|
+
` re-running \`tot ${verb}\` will NOT change that. This usually means one of:`,
|
|
1361
|
+
` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
|
|
1362
|
+
` • your session is scoped to a different store than the one you pushed.`,
|
|
1363
|
+
` Next:`,
|
|
1364
|
+
` • \`tot grants\` — confirm ${tenant} is active for you;`,
|
|
1365
|
+
` • check the preview dashboard for ${tenant} (it will read "Last reconcile: never" until a job runs);`,
|
|
1366
|
+
` • if it stays "never", share this with support: commit ${short}, tenant ${tenant}${ref ? `, ref ${ref}` : ""}.`,
|
|
1367
|
+
];
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1028
1370
|
/**
|
|
1029
1371
|
* 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).
|
|
1372
|
+
* preview URL, open it in the browser (unless opts.open === false). `quiet`
|
|
1373
|
+
* (--json) suppresses ALL printing here — the browser open still runs unless
|
|
1374
|
+
* the caller also passes `open: false` (run() passes `open: false` under
|
|
1375
|
+
* --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
|
|
1376
|
+
* feed the honest never-dispatched block.
|
|
1031
1377
|
*/
|
|
1032
|
-
function reportStatus(s, tenant, { open = true } = {}) {
|
|
1378
|
+
function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview" } = {}) {
|
|
1033
1379
|
if (!s || s.status === "unknown") {
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
`
|
|
1037
|
-
|
|
1380
|
+
if (!quiet) {
|
|
1381
|
+
console.log(
|
|
1382
|
+
` (this MCP doesn't return the per-commit reconcile result yet — your push is in;\n` +
|
|
1383
|
+
` the preview updates once reconcile runs. Check the preview dashboard.)`,
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1038
1386
|
return;
|
|
1039
1387
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1388
|
+
// Never-dispatched dead-end — the honest replacement for false "still reconciling".
|
|
1389
|
+
if (s.notDispatched) {
|
|
1390
|
+
if (!quiet) for (const line of formatNotDispatchedBlock({ commit, ref }, tenant, verb)) console.log(line);
|
|
1042
1391
|
return;
|
|
1043
1392
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1393
|
+
if (s.status === "pending") {
|
|
1394
|
+
if (!quiet) {
|
|
1395
|
+
// Dispatched but not yet reported (real slow reconcile) vs. no job seen yet on
|
|
1396
|
+
// a --no-wait snapshot — say which, and never claim progress we can't see.
|
|
1397
|
+
if (s.dispatched === false) {
|
|
1398
|
+
console.log(` no reconcile job seen yet for ${tenant} — if it doesn't appear shortly, run \`tot grants\` / check the dashboard.`);
|
|
1399
|
+
} else {
|
|
1400
|
+
console.log(` reconcile still running for ${tenant} — check back shortly (re-run \`tot submit --no-wait\`).`);
|
|
1401
|
+
if (s.delivery?.drift) {
|
|
1402
|
+
console.log(` ⚠ a reconcile report exists for a DIFFERENT commit than you pushed — possible tenant-scope mismatch (\`tot grants\` to check your active store).`);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1050
1405
|
}
|
|
1406
|
+
return;
|
|
1051
1407
|
}
|
|
1052
|
-
if (
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1408
|
+
if (!quiet) {
|
|
1409
|
+
const rc = s.reconcile;
|
|
1410
|
+
if (rc) {
|
|
1411
|
+
if (rc.ok) console.log(` ✓ reconcile ok`);
|
|
1412
|
+
else {
|
|
1413
|
+
console.log(` ✗ reconcile failed:`);
|
|
1414
|
+
for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
if (s.compliance?.verdict) {
|
|
1418
|
+
console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
|
|
1419
|
+
for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
|
|
1420
|
+
}
|
|
1421
|
+
if (s.shipped) {
|
|
1422
|
+
console.log(`\n ✓ shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
|
|
1423
|
+
} else if (s.status === "reconciled") {
|
|
1424
|
+
console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
|
|
1425
|
+
}
|
|
1426
|
+
for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
|
|
1060
1427
|
}
|
|
1061
|
-
for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
|
|
1062
1428
|
if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
|
|
1063
|
-
console.log(" (opened in your browser)");
|
|
1429
|
+
if (!quiet) console.log(" (opened in your browser)");
|
|
1064
1430
|
}
|
|
1065
1431
|
}
|