@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.21
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/README.md +12 -9
- package/bin/tot.mjs +219 -44
- package/package.json +7 -2
- package/src/activity.mjs +379 -0
- package/src/app-scaffold.mjs +2 -2
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +137 -0
- package/src/commands/accept.mjs +736 -0
- package/src/commands/app/dev.mjs +7 -3
- package/src/commands/app/index.mjs +2 -2
- package/src/commands/branches.mjs +297 -0
- package/src/commands/cleanup.mjs +269 -0
- package/src/commands/clone.mjs +713 -0
- package/src/commands/dev.mjs +441 -93
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +486 -0
- package/src/commands/grants.mjs +14 -7
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +12 -8
- package/src/commands/pr.mjs +425 -0
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +80 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +403 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +91 -29
- package/src/commands/submit.mjs +1360 -131
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +11 -5
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/dev-heartbeat.mjs +2 -1
- package/src/errors.mjs +8 -4
- package/src/git-credential.mjs +185 -0
- package/src/mcp.mjs +6 -1
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +26 -11
- package/src/obstacle-beacon.cjs +3 -3
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +30 -4
- package/src/validate.mjs +56 -0
- package/src/viewer-session.mjs +118 -0
- package/src/commands/checkout.mjs +0 -330
package/src/commands/submit.mjs
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* From inside a tenant checkout:
|
|
5
5
|
* 1. validate locally and refuse on errors (fail fast before anything leaves the machine),
|
|
6
|
-
* 2. push your committed work to
|
|
6
|
+
* 2. push your committed work to YOUR OWN isolated candidate ref — `candidate/<changeId>`
|
|
7
|
+
* (b03 — no shared `preview` ref is ever force-pushed; an explicit `--ref` can still
|
|
8
|
+
* target a literal ref name for back-compat) — which triggers reconcile,
|
|
7
9
|
* 2b. open/update a PR-BACKED CANDIDATE for the same committed diff (g1b's
|
|
8
10
|
* `candidate_open`, unit c1 — the local-dev-loop half of the "PR-Backed Hosted
|
|
9
11
|
* Review Loop" milestone, symmetric with the hosted s6 draft-as-PR path), and
|
|
@@ -38,20 +40,75 @@
|
|
|
38
40
|
* Dependency-free (global fetch + `git`).
|
|
39
41
|
*/
|
|
40
42
|
import { execFileSync } from "node:child_process";
|
|
43
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
44
|
+
import { resolve as resolvePath } from "node:path";
|
|
41
45
|
import { createHash } from "node:crypto";
|
|
42
46
|
import { setTimeout as delay } from "node:timers/promises";
|
|
43
47
|
import { createMcpClient } from "../mcp.mjs";
|
|
44
48
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
49
|
+
import { checkoutTenant } from "./clone.mjs";
|
|
45
50
|
import { validateTenant, ERROR } from "../validate.mjs";
|
|
46
51
|
import { openBrowser } from "../open.mjs";
|
|
47
52
|
import { startProgress } from "../progress.mjs";
|
|
48
53
|
import { fail } from "../errors.mjs";
|
|
54
|
+
import { emitActivity } from "../activity.mjs";
|
|
55
|
+
import {
|
|
56
|
+
defaultCandidateStatePath,
|
|
57
|
+
readActiveChangeId,
|
|
58
|
+
writeActiveChangeId,
|
|
59
|
+
clearActiveChangeId,
|
|
60
|
+
mintFreshChangeId,
|
|
61
|
+
isTerminalCandidateState,
|
|
62
|
+
isDefaultBranch,
|
|
63
|
+
} from "../candidate-state.mjs";
|
|
49
64
|
|
|
50
65
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
51
|
-
|
|
66
|
+
|
|
67
|
+
// The public storefront origin a shareable preview PR URL is composed against
|
|
68
|
+
// (see shareablePrUrl, below) — the SAME default `tot ship` uses (ship.mjs's
|
|
69
|
+
// DEFAULT_STOREFRONT_URL), so a preview link and a ship link always agree on
|
|
70
|
+
// which storefront they point at even though submit.mjs and ship.mjs never
|
|
71
|
+
// import from each other.
|
|
72
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
73
|
+
|
|
74
|
+
// The git ref prefix an isolated candidate push lands under (b03 — stop
|
|
75
|
+
// force-pushing the SHARED `preview` ref). ONE constant so a rename is trivial —
|
|
76
|
+
// provisionally coordinated with the MCP-side candidate_open resolution (b02/b04),
|
|
77
|
+
// which already names its PR-backed branch `candidate/<changeId>` (see
|
|
78
|
+
// submitCandidate, below): the raw git push here and the PR-backed candidate it
|
|
79
|
+
// opens always target the SAME branch, never two.
|
|
80
|
+
export const CANDIDATE_REF_PREFIX = "candidate/";
|
|
81
|
+
|
|
82
|
+
/** The isolated git ref a candidate's preview push lands under. Pure. */
|
|
83
|
+
export function candidateRefFor(changeId) {
|
|
84
|
+
return `${CANDIDATE_REF_PREFIX}${changeId}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* D3: emit a git-op lifecycle event for `tot submit`/`tot preview` — one per git
|
|
89
|
+
* operation (commit / push), carrying its own success/failure, so the timeline sees
|
|
90
|
+
* the individual git steps, not just the outer command's invoked/result pair. Uses
|
|
91
|
+
* the `cli.command.result` catalog key with a `git.<op>` subcommand (a fixed, safe
|
|
92
|
+
* value). Fire-and-forget best-effort: a silent no-op without a hosted-bridge
|
|
93
|
+
* credential, never awaited, never throws, never alters the command. `errorClass` is
|
|
94
|
+
* a low-cardinality class (never a raw git stderr, which can carry a token/path).
|
|
95
|
+
* @param {string} op @param {boolean} ok
|
|
96
|
+
* @param {{ command?: string, durationMs?: number, errorClass?: string }} [opts]
|
|
97
|
+
*/
|
|
98
|
+
function emitGitOp(op, ok, { command = "submit", durationMs, errorClass } = {}) {
|
|
99
|
+
void emitActivity({
|
|
100
|
+
action: "cli.command.result",
|
|
101
|
+
outcome: { status: ok ? "succeeded" : "failed", ...(durationMs != null ? { durationMs } : {}), ...(errorClass ? { errorClass } : {}) },
|
|
102
|
+
payload: { args: { command, subcommand: `git.${op}`, ...(durationMs != null ? { durationMs } : {}) } },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
52
105
|
|
|
53
106
|
export function parseArgs(argv) {
|
|
54
|
-
|
|
107
|
+
// `ref: null` — an explicit `--ref` always wins; otherwise the push target is
|
|
108
|
+
// derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
|
|
109
|
+
// never a fixed shared default.
|
|
110
|
+
/** @type {{ mcp: string|null, identity: string|null, ref: string|null, skipValidate: boolean, skipFreshness: boolean, noWait: boolean, watch: boolean, noOpen: boolean, noCommit: boolean, message: string|null, summary: string|null, summaryFile: string|null, json: boolean, new: boolean, help: boolean }} */
|
|
111
|
+
const a = { mcp: null, identity: null, ref: null, skipValidate: false, skipFreshness: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, json: false, new: false, help: false };
|
|
55
112
|
for (let i = 0; i < argv.length; i++) {
|
|
56
113
|
const t = argv[i];
|
|
57
114
|
if (t === "--mcp") a.mcp = argv[++i];
|
|
@@ -59,29 +116,65 @@ export function parseArgs(argv) {
|
|
|
59
116
|
else if (t === "--ref") a.ref = argv[++i];
|
|
60
117
|
else if (t === "-m" || t === "--message") a.message = argv[++i];
|
|
61
118
|
else if (t === "--summary") a.summary = argv[++i];
|
|
119
|
+
else if (t === "--summary-file") a.summaryFile = argv[++i];
|
|
120
|
+
else if (t === "--json") a.json = true;
|
|
62
121
|
else if (t === "--skip-validate") a.skipValidate = true;
|
|
122
|
+
else if (t === "--skip-freshness") a.skipFreshness = true;
|
|
123
|
+
else if (t === "--no-commit") a.noCommit = true;
|
|
63
124
|
else if (t === "--no-wait") a.noWait = true;
|
|
64
125
|
else if (t === "--watch") a.watch = true;
|
|
65
126
|
else if (t === "--no-open") a.noOpen = true;
|
|
127
|
+
else if (t === "--new") a.new = true;
|
|
66
128
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
67
129
|
}
|
|
68
130
|
return a;
|
|
69
131
|
}
|
|
70
132
|
|
|
71
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Render the usage block for whichever verb invoked this flow. `preview` is the
|
|
135
|
+
* first-class verb; `submit`/`deploy` reach the same flow as teaching aliases, so
|
|
136
|
+
* the help they print names the verb the developer actually typed (see preview.mjs).
|
|
137
|
+
* @param {string} [verb]
|
|
138
|
+
*/
|
|
139
|
+
export function renderUsage(verb = "preview") {
|
|
140
|
+
return `tot ${verb} — submit your store for preview
|
|
141
|
+
|
|
142
|
+
tot ${verb} validate → push the preview ref → stream the result
|
|
143
|
+
tot ${verb} --new open a NEW candidate PR instead of updating your open one
|
|
144
|
+
tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
|
|
145
|
+
tot ${verb} --skip-validate push without the local lint (not recommended)
|
|
146
|
+
tot ${verb} --skip-freshness skip the stale-base check (not recommended — may build a
|
|
147
|
+
candidate rooted in an already-superseded base)
|
|
148
|
+
tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
|
|
149
|
+
tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
|
|
150
|
+
tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
|
|
151
|
+
tot ${verb} --summary "<text>" longer description to accompany the title
|
|
152
|
+
tot ${verb} --summary-file <path> structured summary from a file — JSON
|
|
153
|
+
{intent,effect,verification,risk} or the labeled
|
|
154
|
+
Intent / User-visible effect / Verification /
|
|
155
|
+
Risk-rollback text block; pass "-" to read stdin
|
|
156
|
+
(mutually exclusive with --summary)
|
|
157
|
+
tot ${verb} --no-wait push and exit without polling for the reconcile result
|
|
158
|
+
tot ${verb} --no-open don't open the preview URL in the browser on success
|
|
159
|
+
tot ${verb} --json machine-readable result on stdout (candidate id, PR,
|
|
160
|
+
head SHA, preview URL, reconcile/compliance evidence)
|
|
161
|
+
— implies --no-open, no spinner
|
|
162
|
+
tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
72
163
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
tot
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
164
|
+
By default a re-run UPDATES your open candidate PR (like pushing more commits
|
|
165
|
+
to a GitHub PR), rather than opening a new one each time. Use --new to fork a
|
|
166
|
+
fresh candidate PR; the next plain \`tot ${verb}\` then updates THAT one. Manage your
|
|
167
|
+
open candidates with \`tot pr\` (list / view / close). If your candidate was already
|
|
168
|
+
merged or closed, a re-run automatically opens a fresh one.
|
|
169
|
+
|
|
170
|
+
Working on one thing at a time? You don't need --new, a git branch, or any
|
|
171
|
+
branch management at all — just keep editing and re-running \`tot ${verb}\`.
|
|
172
|
+
|
|
173
|
+
Once a preview reconciles cleanly, \`tot ship\` promotes it live.
|
|
82
174
|
|
|
83
175
|
If you omit -m, a summary is generated from git (commit subject + the diff vs
|
|
84
176
|
what's live in preview) so the change record the approver reviews is never blank.`;
|
|
177
|
+
}
|
|
85
178
|
|
|
86
179
|
// Default bounded wait (~20s, matching the pre-E2 fixed poll's total budget) vs.
|
|
87
180
|
// --watch's longer per-call long-poll + more attempts (~8 min ceiling) for a dev
|
|
@@ -115,14 +208,362 @@ export function buildChangeSummary({ message, summary, headSubject = "", statLin
|
|
|
115
208
|
|
|
116
209
|
/** Print the change summary block — the SAME title/body carried into `candidate_open`
|
|
117
210
|
* (below) as the PR title/description, so what the approver reads in the PR matches
|
|
118
|
-
* what's printed here.
|
|
119
|
-
|
|
211
|
+
* what's printed here. `quiet` (--json) suppresses the human-readable print — the
|
|
212
|
+
* same data reaches the caller via the JSON result instead (see buildJsonResult). */
|
|
213
|
+
function printChangeSummary({ title, body, autoTitle }, { quiet = false } = {}) {
|
|
214
|
+
if (quiet) return;
|
|
120
215
|
console.log(`\n Change summary (for the approver / the change record):`);
|
|
121
216
|
console.log(` ${title}`);
|
|
122
217
|
for (const l of body) console.log(` ${l}`);
|
|
123
218
|
if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
|
|
124
219
|
}
|
|
125
220
|
|
|
221
|
+
// ─── structured candidate summary (--summary-file / stdin, P2 item 14) ──────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The four fields the candidate body prefers (see
|
|
225
|
+
* docs/architecture/branch-lifecycle-and-integration-preview.md §"Change
|
|
226
|
+
* descriptions and AI assistance") — `key` is the JSON key, `label` the
|
|
227
|
+
* canonical text-block heading, `match` the label spellings
|
|
228
|
+
* parseLabeledSummary recognizes for that field (case-insensitive).
|
|
229
|
+
*/
|
|
230
|
+
const STRUCTURED_FIELDS = [
|
|
231
|
+
{ key: "intent", label: "Intent", match: /^intent$/i },
|
|
232
|
+
{ key: "effect", label: "User-visible effect", match: /^(user-visible effect|effect)$/i },
|
|
233
|
+
{ key: "verification", label: "Verification", match: /^verification$/i },
|
|
234
|
+
{ key: "risk", label: "Risk / rollback", match: /^(risk\s*\/?\s*rollback|risk-rollback|risk)$/i },
|
|
235
|
+
];
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Parse the labeled four-field text block (`Intent: …` / `User-visible effect: …`
|
|
239
|
+
* / `Verification: …` / `Risk / rollback: …`) into `{intent, effect, verification,
|
|
240
|
+
* risk}` — a field's value continues across following lines until the next
|
|
241
|
+
* recognized label, so multi-line prose under one label is preserved. Unlabeled
|
|
242
|
+
* leading text (and anything before the first recognized label) is dropped —
|
|
243
|
+
* callers fall back to treating the whole file as freeform body when nothing
|
|
244
|
+
* matches at all. Pure — unit-tested.
|
|
245
|
+
* @param {string} text
|
|
246
|
+
* @returns {{intent?: string, effect?: string, verification?: string, risk?: string}}
|
|
247
|
+
*/
|
|
248
|
+
export function parseLabeledSummary(text) {
|
|
249
|
+
const fields = {};
|
|
250
|
+
let current = null;
|
|
251
|
+
let buf = [];
|
|
252
|
+
const flush = () => {
|
|
253
|
+
if (current) fields[current] = buf.join("\n").trim();
|
|
254
|
+
buf = [];
|
|
255
|
+
};
|
|
256
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
257
|
+
const m = /^([A-Za-z][A-Za-z /-]*?)\s*:\s*(.*)$/.exec(line);
|
|
258
|
+
const field = m && STRUCTURED_FIELDS.find((f) => f.match.test(m[1].trim()));
|
|
259
|
+
if (field) {
|
|
260
|
+
flush();
|
|
261
|
+
current = field.key;
|
|
262
|
+
buf = m[2] ? [m[2]] : [];
|
|
263
|
+
} else if (current) {
|
|
264
|
+
buf.push(line);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
flush();
|
|
268
|
+
return fields;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Parse `--summary-file` content as JSON — `{intent, effect, verification, risk}`
|
|
273
|
+
* (extra keys ignored, each value trimmed, blank/non-string values dropped).
|
|
274
|
+
* Returns null when the text isn't a JSON object at all, so the caller falls
|
|
275
|
+
* back to the labeled-text parser rather than treating a JSON parse error as
|
|
276
|
+
* "no fields". Pure — unit-tested.
|
|
277
|
+
* @param {string} text
|
|
278
|
+
* @returns {{intent?: string, effect?: string, verification?: string, risk?: string}|null}
|
|
279
|
+
*/
|
|
280
|
+
export function parseJsonSummary(text) {
|
|
281
|
+
let obj;
|
|
282
|
+
try {
|
|
283
|
+
obj = JSON.parse(text);
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
|
|
288
|
+
const fields = {};
|
|
289
|
+
for (const { key } of STRUCTURED_FIELDS) {
|
|
290
|
+
if (typeof obj[key] === "string" && obj[key].trim()) fields[key] = obj[key].trim();
|
|
291
|
+
}
|
|
292
|
+
return fields;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Render recognized structured fields back to the canonical labeled block — the
|
|
297
|
+
* SAME shape the contract prescribes — so a JSON or labeled-text `--summary-file`
|
|
298
|
+
* produces an identical PR/candidate body to a human writing
|
|
299
|
+
* `--summary "Intent: …"` by hand. Only fields actually present are emitted.
|
|
300
|
+
* Pure — unit-tested.
|
|
301
|
+
* @param {{intent?: string, effect?: string, verification?: string, risk?: string}} fields
|
|
302
|
+
* @returns {string[]}
|
|
303
|
+
*/
|
|
304
|
+
export function formatStructuredSummary(fields) {
|
|
305
|
+
return STRUCTURED_FIELDS.filter(({ key }) => fields[key]).map(({ key, label }) => `${label}: ${fields[key]}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Resolve `--summary-file`/stdin content to `--summary`'s body text (so it
|
|
310
|
+
* flows through buildChangeSummary/candidate_open exactly like an inline
|
|
311
|
+
* `--summary`, per the "preserve -m/--summary unchanged" requirement — this is
|
|
312
|
+
* purely an alternate SOURCE for the same body string). JSON wins if the
|
|
313
|
+
* content parses as an object with at least one recognized field; else the
|
|
314
|
+
* labeled text block; else — no recognized structure at all — the raw content
|
|
315
|
+
* is used verbatim as freeform summary text, so automation isn't forced into
|
|
316
|
+
* the four-field shape. Pure — unit-tested.
|
|
317
|
+
* @param {string} text
|
|
318
|
+
* @returns {string}
|
|
319
|
+
*/
|
|
320
|
+
export function summaryFromStructuredText(text) {
|
|
321
|
+
const trimmed = String(text ?? "").trim();
|
|
322
|
+
if (!trimmed) return "";
|
|
323
|
+
const json = parseJsonSummary(trimmed);
|
|
324
|
+
if (json && Object.keys(json).length) return formatStructuredSummary(json).join("\n");
|
|
325
|
+
const labeled = parseLabeledSummary(trimmed);
|
|
326
|
+
if (Object.keys(labeled).length) return formatStructuredSummary(labeled).join("\n");
|
|
327
|
+
return trimmed;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Read `--summary-file <path>` content — `"-"` reads stdin (fd 0), the same
|
|
331
|
+
* dash-means-stdin convention `tot app` uses (see app/dev.mjs readInput), so
|
|
332
|
+
* an LLM/automation caller can pipe the structured summary in without a temp
|
|
333
|
+
* file. Throws on a real read failure (missing file, permissions) — the
|
|
334
|
+
* caller reports it. */
|
|
335
|
+
export function readSummaryFileContent(pathOrDash) {
|
|
336
|
+
if (pathOrDash === "-") return readFileSync(0, "utf8");
|
|
337
|
+
return readFileSync(pathOrDash, "utf8");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ─── stale-base freshness preflight (unit u16) ──────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/** The candidate base branch every submit targets (mirrors tot-mcp's own
|
|
343
|
+
* `DEFAULT_CANDIDATE_BASE` and sync.mjs's `DEFAULT_SYNC_BRANCH` — the SAME
|
|
344
|
+
* protected branch by three different names in three different modules; kept
|
|
345
|
+
* a local constant here, not imported, since this file already resolves its
|
|
346
|
+
* own defaults independently of sync.mjs and candidate_open's server default). */
|
|
347
|
+
export const FRESHNESS_BASE_BRANCH = "preview";
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Detect a STALE local view of the base branch before minting a candidate —
|
|
351
|
+
* the live-repeat incident this guards against: the checkout's `origin/preview`
|
|
352
|
+
* tracking ref was stale (recorded before a just-merged PR moved it), so a
|
|
353
|
+
* fresh `tot preview --new` built a candidate rooted in the OLD tip and got an
|
|
354
|
+
* instant, entirely avoidable "not mergeable" the moment it was compared
|
|
355
|
+
* against the real, already-advanced `preview`.
|
|
356
|
+
*
|
|
357
|
+
* Compares what the LOCAL checkout believes the base's tip is
|
|
358
|
+
* (`refs/remotes/origin/<branch>`, only as fresh as the last explicit fetch)
|
|
359
|
+
* against its ACTUAL current tip on the forge (`git ls-remote`, a lightweight
|
|
360
|
+
* single-ref read — no full fetch, no local ref mutated). Returns the live
|
|
361
|
+
* remote sha when local's cached view is behind it, or `null` when: the two
|
|
362
|
+
* already agree, there is no local tracking ref to compare against yet (a
|
|
363
|
+
* checkout that has simply never fetched this branch — never a false block on
|
|
364
|
+
* that), or the remote can't be reached right now (a network hiccup must
|
|
365
|
+
* never block a submit that would otherwise succeed; the push itself is the
|
|
366
|
+
* real connectivity test). Pure git I/O via the injected runner — unit-tested.
|
|
367
|
+
* @param {(cargs:string[])=>string} git
|
|
368
|
+
* @param {string} branch
|
|
369
|
+
* @returns {string|null}
|
|
370
|
+
*/
|
|
371
|
+
export function detectStaleBase(git, branch) {
|
|
372
|
+
let localSha = "";
|
|
373
|
+
try {
|
|
374
|
+
localSha = git(["rev-parse", "-q", "--verify", `refs/remotes/origin/${branch}`]).trim();
|
|
375
|
+
} catch {
|
|
376
|
+
return null; // never fetched this branch locally — nothing cached to be stale
|
|
377
|
+
}
|
|
378
|
+
if (!localSha) return null;
|
|
379
|
+
let remoteSha = "";
|
|
380
|
+
try {
|
|
381
|
+
remoteSha = (git(["ls-remote", "origin", branch]).split(/\s+/)[0] || "").trim();
|
|
382
|
+
} catch {
|
|
383
|
+
return null; // can't reach the remote right now — don't block on a network hiccup
|
|
384
|
+
}
|
|
385
|
+
if (!remoteSha || remoteSha === localSha) return null;
|
|
386
|
+
return remoteSha;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── auto-commit the known content trees (unit u2) ───────────────────────────────
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The content trees `tot preview` may auto-commit on a dirty tree — and ONLY
|
|
393
|
+
* these. A store's reviewable content lives here; anything a developer edits
|
|
394
|
+
* OUTSIDE these (src/, config, stray files) is out of scope for an automatic
|
|
395
|
+
* commit and is REFUSED rather than silently swept in. Explicit paths only —
|
|
396
|
+
* never `git add -A`/`git add .`.
|
|
397
|
+
*/
|
|
398
|
+
export const KNOWN_CONTENT_TREES = ["content/", "public/", ".tot/"];
|
|
399
|
+
export const KNOWN_CONTENT_FILES = ["theme.json"];
|
|
400
|
+
|
|
401
|
+
/** Is this repo-relative path inside a known content tree (or the one known
|
|
402
|
+
* top-level file)? Pure — unit-tested. */
|
|
403
|
+
export function isKnownContentPath(path) {
|
|
404
|
+
const p = String(path).replace(/^\.\//, "");
|
|
405
|
+
return KNOWN_CONTENT_FILES.includes(p) || KNOWN_CONTENT_TREES.some((t) => p.startsWith(t));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Flatten `git status --porcelain` (v1) output to the ordered, deduped set of
|
|
410
|
+
* dirty repo-relative paths. Each line is `XY <path>`; a rename/copy is
|
|
411
|
+
* `XY <old> -> <new>` and contributes BOTH paths (the old one is being removed
|
|
412
|
+
* from its tree too, so it's part of the scope decision). Run with
|
|
413
|
+
* `core.quotePath=false` upstream so unicode paths arrive literal. Pure —
|
|
414
|
+
* unit-tested.
|
|
415
|
+
* @param {string} text
|
|
416
|
+
* @returns {string[]}
|
|
417
|
+
*/
|
|
418
|
+
export function parsePorcelainPaths(text) {
|
|
419
|
+
const seen = new Set();
|
|
420
|
+
const out = [];
|
|
421
|
+
for (const line of String(text).split("\n")) {
|
|
422
|
+
if (line.length < 4) continue; // "XY p" is the shortest real entry
|
|
423
|
+
const rest = line.slice(3);
|
|
424
|
+
const parts = rest.includes(" -> ") ? rest.split(" -> ") : [rest];
|
|
425
|
+
for (const raw of parts) {
|
|
426
|
+
const p = raw.trim();
|
|
427
|
+
if (p && !seen.has(p)) {
|
|
428
|
+
seen.add(p);
|
|
429
|
+
out.push(p);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Partition dirty paths into the known content trees vs everything else. Pure —
|
|
438
|
+
* unit-tested.
|
|
439
|
+
* @param {string[]} paths
|
|
440
|
+
* @returns {{ known: string[], unknown: string[] }}
|
|
441
|
+
*/
|
|
442
|
+
export function classifyDirtyPaths(paths) {
|
|
443
|
+
const known = [];
|
|
444
|
+
const unknown = [];
|
|
445
|
+
for (const p of paths) (isKnownContentPath(p) ? known : unknown).push(p);
|
|
446
|
+
return { known, unknown };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** A concise auto commit subject when the developer gave no -m — derived from the
|
|
450
|
+
* staged files so the commit is never a blank "(untitled change)". Pure. */
|
|
451
|
+
export function autoCommitSubject(files = []) {
|
|
452
|
+
if (files.length === 0) return "update store content";
|
|
453
|
+
if (files.length === 1) return `update ${files[0]}`;
|
|
454
|
+
return `update ${files.length} content files`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The message for `tot preview`'s pre-preview auto-commit. An explicit `-m` wins
|
|
459
|
+
* as the subject; otherwise autoCommitSubject derives one from the staged files.
|
|
460
|
+
* The body (shortstat + file list) is built by the SAME buildChangeSummary the
|
|
461
|
+
* approver summary uses, so a commit and the change record it later feeds read
|
|
462
|
+
* consistently. Returns a git commit message (subject, blank line, body). Pure —
|
|
463
|
+
* unit-tested.
|
|
464
|
+
* @param {{ message?: string|null, files?: string[], statLine?: string }} input
|
|
465
|
+
* @returns {string}
|
|
466
|
+
*/
|
|
467
|
+
export function buildAutoCommitMessage({ message, files = [], statLine = "" } = {}) {
|
|
468
|
+
const subject = (message && message.trim()) || autoCommitSubject(files);
|
|
469
|
+
const { body } = buildChangeSummary({ message: subject, files, statLine });
|
|
470
|
+
return body.length ? `${subject}\n\n${body.join("\n")}` : subject;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Detect an in-progress rebase, merge, or cherry-pick in the workspace (unit u8) —
|
|
475
|
+
* `tot preview`/`tot submit` must NEVER auto-commit over one of these. A rebase that
|
|
476
|
+
* stopped at "Could not apply" (or a merge/cherry-pick left with real conflicts) IS
|
|
477
|
+
* a dirty tree from `git status`'s point of view, so `autoCommitKnownTrees` would
|
|
478
|
+
* otherwise stage + commit the half-resolved content straight into a plain "content
|
|
479
|
+
* update" commit — silently finishing the git operation WRONG and losing whatever
|
|
480
|
+
* edit was still sitting in conflict markers or unresolved hunks (the live incident
|
|
481
|
+
* this guards against: a stalled rebase was never continued, `tot preview` ran
|
|
482
|
+
* anyway, and the developer's own edit was gone). Detection is worktree-safe:
|
|
483
|
+
* MERGE_HEAD/CHERRY_PICK_HEAD via plumbing refs (no direct `.git` path assumption),
|
|
484
|
+
* and rebase-merge/rebase-apply via `--git-path` (a linked worktree's git-dir lives
|
|
485
|
+
* OUTSIDE `<workspace>/.git`, so a literal `.git/rebase-merge` check would miss it).
|
|
486
|
+
* Returns which operation is in progress, or null when the tree is clean of one.
|
|
487
|
+
* @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
|
|
488
|
+
* @param {string} workspace absolute path `git` runs `-C` against — `--git-path`'s
|
|
489
|
+
* output may be relative, and Node's `existsSync` resolves relative paths against
|
|
490
|
+
* the CLI process's own cwd, not the checkout, so this pins the resolution base.
|
|
491
|
+
* @returns {"rebase"|"merge"|"cherry-pick"|null}
|
|
492
|
+
*/
|
|
493
|
+
export function detectInProgressGitOperation(git, workspace) {
|
|
494
|
+
const hasRef = (name) => {
|
|
495
|
+
try {
|
|
496
|
+
return git(["rev-parse", "-q", "--verify", name]).trim().length > 0;
|
|
497
|
+
} catch {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
if (hasRef("MERGE_HEAD")) return "merge";
|
|
502
|
+
if (hasRef("CHERRY_PICK_HEAD")) return "cherry-pick";
|
|
503
|
+
const hasGitPath = (name) => {
|
|
504
|
+
try {
|
|
505
|
+
const p = git(["rev-parse", "--git-path", name]).trim();
|
|
506
|
+
// An empty result should never happen for a real `--git-path` (it always
|
|
507
|
+
// echoes SOME path, existing or not) — but treat it as "absent" rather than
|
|
508
|
+
// resolving it, since `resolvePath(workspace, "")` degrades to `workspace`
|
|
509
|
+
// itself, which trivially always exists (a false "rebase in progress" on
|
|
510
|
+
// every call, not just an occasional false negative).
|
|
511
|
+
return p.length > 0 && existsSync(resolvePath(workspace, p));
|
|
512
|
+
} catch {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
if (hasGitPath("rebase-merge") || hasGitPath("rebase-apply")) return "rebase";
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The abort command that recovers from each in-progress git operation, so the
|
|
521
|
+
* refusal below can tell a developer exactly what to run. Pure. */
|
|
522
|
+
export function abortCommandFor(op) {
|
|
523
|
+
return op === "merge" ? "git merge --abort" : op === "cherry-pick" ? "git cherry-pick --abort" : "git rebase --abort";
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Auto-commit the known content trees before previewing (unit u2). On a DIRTY
|
|
528
|
+
* tree `tot preview` commits your content edits for you, so a preview always
|
|
529
|
+
* reflects your working changes — but ONLY the known trees (content/, public/,
|
|
530
|
+
* theme.json, .tot/), staged by EXPLICIT path (never `git add -A`). If anything
|
|
531
|
+
* dirty falls OUTSIDE those, it REFUSES (out-of-scope src/config/stray edits are
|
|
532
|
+
* never silently swept into a store commit). --no-commit opts out entirely
|
|
533
|
+
* (preview whatever's already committed — u1's behavior).
|
|
534
|
+
*
|
|
535
|
+
* Returns exactly one of:
|
|
536
|
+
* { skipped: true } — --no-commit.
|
|
537
|
+
* { inProgress: "rebase"|… } — a rebase/merge/cherry-pick is unresolved;
|
|
538
|
+
* caller refuses rather than auto-committing
|
|
539
|
+
* over the developer's own half-resolved tree.
|
|
540
|
+
* { clean: true } — nothing dirty; preview HEAD as-is.
|
|
541
|
+
* { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
|
|
542
|
+
* { committed: true, sha, files } — staged the known dirty paths and committed.
|
|
543
|
+
*
|
|
544
|
+
* @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
|
|
545
|
+
* @param {{ message?: string|null, noCommit?: boolean, workspace?: string }} [opts]
|
|
546
|
+
*/
|
|
547
|
+
export function autoCommitKnownTrees(git, { message = null, noCommit = false, workspace = "." } = {}) {
|
|
548
|
+
if (noCommit) return { skipped: true };
|
|
549
|
+
const inProgress = detectInProgressGitOperation(git, workspace);
|
|
550
|
+
if (inProgress) return { inProgress };
|
|
551
|
+
const status = git(["-c", "core.quotePath=false", "status", "--porcelain", "--untracked-files=all"]);
|
|
552
|
+
const dirty = parsePorcelainPaths(status);
|
|
553
|
+
if (dirty.length === 0) return { clean: true };
|
|
554
|
+
const { known, unknown } = classifyDirtyPaths(dirty);
|
|
555
|
+
if (unknown.length) return { refused: true, unknown, known };
|
|
556
|
+
// Stage ONLY the known dirty paths, each by explicit path — this records adds,
|
|
557
|
+
// modifications AND deletions within them, and can never reach outside the set.
|
|
558
|
+
git(["add", "--", ...known]);
|
|
559
|
+
const files = parseNameStatus(git(["diff", "--cached", "--name-status"])).map((e) => e.path);
|
|
560
|
+
const statLine = git(["diff", "--cached", "--shortstat"]).trim();
|
|
561
|
+
const msg = buildAutoCommitMessage({ message, files, statLine });
|
|
562
|
+
git(["commit", "--no-verify", "-m", msg]);
|
|
563
|
+
const sha = git(["rev-parse", "HEAD"]).trim();
|
|
564
|
+
return { committed: true, sha, files };
|
|
565
|
+
}
|
|
566
|
+
|
|
126
567
|
// ─── PR-backed candidate (g1b candidate_open, unit c1) ──────────────────────────
|
|
127
568
|
|
|
128
569
|
/**
|
|
@@ -161,16 +602,17 @@ export function parseNameStatus(text) {
|
|
|
161
602
|
* @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
|
|
162
603
|
*/
|
|
163
604
|
export function buildFilePatch(entries, readBlob) {
|
|
605
|
+
/** @type {Array<{ path: string, content?: string, contentEncoding?: "base64", delete?: true }>} */
|
|
164
606
|
const patch = [];
|
|
165
607
|
for (const e of entries) {
|
|
166
|
-
if (e.status === "R") patch.push({ path: e.from, delete: true });
|
|
608
|
+
if (e.status === "R") patch.push({ path: /** @type {string} */ (e.from), delete: true });
|
|
167
609
|
if (e.status === "D") {
|
|
168
610
|
patch.push({ path: e.path, delete: true });
|
|
169
611
|
continue;
|
|
170
612
|
}
|
|
171
613
|
const buf = readBlob(e.path);
|
|
172
614
|
const asUtf8 = buf.toString("utf8");
|
|
173
|
-
const isCleanUtf8 = !asUtf8.includes("
|
|
615
|
+
const isCleanUtf8 = !asUtf8.includes("\x00") && Buffer.from(asUtf8, "utf8").equals(buf);
|
|
174
616
|
patch.push(
|
|
175
617
|
isCleanUtf8
|
|
176
618
|
? { path: e.path, content: asUtf8 }
|
|
@@ -198,22 +640,138 @@ export function repoNameFromRemote(remoteUrl) {
|
|
|
198
640
|
}
|
|
199
641
|
}
|
|
200
642
|
|
|
643
|
+
// ─── fresh-forge-credential push (decision B — the invited-dev 401 dead-end) ─────
|
|
644
|
+
|
|
645
|
+
// splitAuthedRemote / basicAuthExtraHeader now live in ../git-credential.mjs (unit
|
|
646
|
+
// u10) — a dependency-free module BOTH this file and clone.mjs/commands/
|
|
647
|
+
// git-credential.mjs need, so they moved out of here to avoid a submit.mjs ↔
|
|
648
|
+
// clone.mjs import cycle. Re-exported so every existing import of these two names
|
|
649
|
+
// FROM submit.mjs (this file's own callers below, plus tests) keeps working
|
|
650
|
+
// unchanged.
|
|
651
|
+
export { splitAuthedRemote, basicAuthExtraHeader } from "../git-credential.mjs";
|
|
652
|
+
import { splitAuthedRemote, basicAuthExtraHeader, ensureTokenlessRemote } from "../git-credential.mjs";
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Recognise a forge auth failure (expired / invalid push token) in a failed git
|
|
656
|
+
* push's stderr, so `tot preview` can re-mint a fresh credential and retry once
|
|
657
|
+
* rather than dead-ending on a stale token (the belt-and-suspenders half of
|
|
658
|
+
* decision B). Pure — unit-tested.
|
|
659
|
+
* @param {string} text
|
|
660
|
+
* @returns {boolean}
|
|
661
|
+
*/
|
|
662
|
+
export function isForgeAuthError(text) {
|
|
663
|
+
return /\b40[13]\b|failed to authenticate|authentication failed|invalid credentials|access denied/i.test(
|
|
664
|
+
String(text || ""),
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Derive the checkout's forge tag from its repo name (`"<tenant>-<tag>"`), so a
|
|
670
|
+
* re-mint targets the SAME repo the checkout points at. Defaults to "main" when the
|
|
671
|
+
* repo is bare (`"<tenant>"`, post-8425) or the `<tenant>-` prefix doesn't match, so
|
|
672
|
+
* the mint degrades to the clone default rather than a wrong tag. Pure —
|
|
673
|
+
* unit-tested.
|
|
674
|
+
* @param {string|null} repoName
|
|
675
|
+
* @param {string} tenant
|
|
676
|
+
* @returns {string}
|
|
677
|
+
*/
|
|
678
|
+
export function tagFromRepoName(repoName, tenant) {
|
|
679
|
+
const r = String(repoName || "");
|
|
680
|
+
const prefix = `${tenant}-`;
|
|
681
|
+
return r.startsWith(prefix) && r.length > prefix.length ? r.slice(prefix.length) : "main";
|
|
682
|
+
}
|
|
683
|
+
|
|
201
684
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
685
|
+
* Push the preview ref (decision B): mint a FRESH, short-lived forge credential
|
|
686
|
+
* right before the push and hand it to git EPHEMERALLY (via `http.extraheader` on
|
|
687
|
+
* a per-invocation `-c` — never written to `.git/config`), re-minting once on an
|
|
688
|
+
* auth failure. The push credential the MCP baked into `.git/config` at clone time
|
|
689
|
+
* expires within hours; reusing that stale embedded token is the invited-dev
|
|
690
|
+
* "`tot preview` → Gitea 401 dead-end". We push over the named `origin` remote with
|
|
691
|
+
* its URL overridden to the tokenless public URL for this one invocation, so the
|
|
692
|
+
* remote-tracking ref still updates while no long-lived secret lands on disk.
|
|
693
|
+
*
|
|
694
|
+
* When the mint is unavailable (older MCP, transient failure — `mintRemote` returns
|
|
695
|
+
* null) or the minted URL carries no parseable token, it falls back to pushing over
|
|
696
|
+
* the checkout's EXISTING remote (pre-B behavior) — no regression.
|
|
697
|
+
*
|
|
698
|
+
* @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
|
|
699
|
+
* @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
|
|
700
|
+
* @param {{ ref?: string }} [opts]
|
|
701
|
+
* @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
|
|
702
|
+
*/
|
|
703
|
+
export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
|
|
704
|
+
const attempt = (remote) => {
|
|
705
|
+
const cred = splitAuthedRemote(remote);
|
|
706
|
+
if (!cred) {
|
|
707
|
+
// No fresh credential to hand over — push over the checkout's existing remote.
|
|
708
|
+
return git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
|
|
709
|
+
}
|
|
710
|
+
// Ephemeral auth: override the remote URL to the tokenless public URL and supply
|
|
711
|
+
// the credential as a one-shot Authorization header, with any OS credential
|
|
712
|
+
// helper disabled — none of this touches `.git/config`.
|
|
713
|
+
return git([
|
|
714
|
+
"-c", `remote.origin.url=${cred.publicUrl}`,
|
|
715
|
+
"-c", `http.extraheader=${basicAuthExtraHeader(cred.username, cred.token)}`,
|
|
716
|
+
"-c", "credential.helper=",
|
|
717
|
+
"push", "-f", "origin", `HEAD:refs/heads/${ref}`,
|
|
718
|
+
]);
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const remote = await mintRemote();
|
|
722
|
+
try {
|
|
723
|
+
return { out: attempt(remote) };
|
|
724
|
+
} catch (e) {
|
|
725
|
+
if (!isForgeAuthError(e?.stderr || e?.message || e)) throw e;
|
|
726
|
+
// Belt-and-suspenders: an auth failure re-mints a fresh credential and retries once.
|
|
727
|
+
return { out: attempt(await mintRemote()) };
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* A STABLE per-developer-per-tenant(-per-branch) candidate handle — so repeat
|
|
733
|
+
* `tot submit` runs update the SAME PR instead of opening a new one each time
|
|
204
734
|
* (`candidate_open` is idempotent on `changeId`). No local state file needed:
|
|
205
|
-
* it's a deterministic hash of the tenant + the acting identity
|
|
206
|
-
* fresh every run. Two different developers
|
|
207
|
-
* two different (non-colliding) candidates
|
|
735
|
+
* it's a deterministic hash of the tenant + the acting identity (+ the git branch
|
|
736
|
+
* on a non-default branch), recomputed fresh every run. Two different developers
|
|
737
|
+
* submitting to the same tenant get two different (non-colliding) candidates; so
|
|
738
|
+
* do the SAME developer on two different feature branches (u4 — branch-bound
|
|
739
|
+
* candidates), so `git checkout` acts as the PR switcher.
|
|
740
|
+
*
|
|
741
|
+
* ZERO MIGRATION: on the DEFAULT branch (main/master, or an unresolvable branch)
|
|
742
|
+
* the hash material is `tenant|actorKey` — byte-identical to the pre-u4 id — so an
|
|
743
|
+
* existing dev's current candidate keeps working untouched. A non-default branch
|
|
744
|
+
* folds the branch into the material (`tenant|actorKey|branch`) for its own id.
|
|
745
|
+
* Pure — unit-tested.
|
|
208
746
|
* @param {string} tenant
|
|
209
747
|
* @param {string} actorKey
|
|
748
|
+
* @param {string|null} [branch] current git branch; default/null ⇒ today's id
|
|
210
749
|
* @returns {string}
|
|
211
750
|
*/
|
|
212
|
-
export function deriveChangeId(tenant, actorKey) {
|
|
213
|
-
const
|
|
751
|
+
export function deriveChangeId(tenant, actorKey, branch = null) {
|
|
752
|
+
const material = isDefaultBranch(branch) ? `${tenant}|${actorKey}` : `${tenant}|${actorKey}|${branch}`;
|
|
753
|
+
const hash = createHash("sha256").update(material).digest("hex").slice(0, 16);
|
|
214
754
|
return `local-${hash}`;
|
|
215
755
|
}
|
|
216
756
|
|
|
757
|
+
/**
|
|
758
|
+
* The current git branch in `workspace`, or null when it can't be resolved (a
|
|
759
|
+
* detached HEAD reports "HEAD", and any git failure is swallowed) — null is read
|
|
760
|
+
* by `isDefaultBranch` as the default branch, so an unresolvable branch keeps
|
|
761
|
+
* today's (branch-less) candidate rather than minting a spurious namespace. `git`
|
|
762
|
+
* is injected (a `(args:string[])=>string` runner) so it's testable. Best-effort.
|
|
763
|
+
* @param {(args:string[]) => string} git
|
|
764
|
+
* @returns {string|null}
|
|
765
|
+
*/
|
|
766
|
+
export function currentBranch(git) {
|
|
767
|
+
try {
|
|
768
|
+
const b = git(["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
769
|
+
return b && b !== "HEAD" ? b : null;
|
|
770
|
+
} catch {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
217
775
|
/** The stable identity key behind `deriveChangeId` — the signed-in developer's
|
|
218
776
|
* email, falling back to the token, then a generic label. Single-plane: the only
|
|
219
777
|
* identity `tot` carries is the developer's own OAuth session. */
|
|
@@ -221,6 +779,97 @@ export function actorKeyFor(session) {
|
|
|
221
779
|
return session?.email || session?.token || "developer";
|
|
222
780
|
}
|
|
223
781
|
|
|
782
|
+
/**
|
|
783
|
+
* Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
|
|
784
|
+
* network call, because it also determines the isolated git ref we push to
|
|
785
|
+
* (resolvePushRef, below): a re-submit updates the SAME candidate/ref by default;
|
|
786
|
+
* `--new` forks a fresh one.
|
|
787
|
+
* --new → fork a FRESH candidate id;
|
|
788
|
+
* otherwise → the remembered active candidate (from a prior --new / terminal
|
|
789
|
+
* roll), else the STABLE per-dev-per-tenant(-per-branch) default.
|
|
790
|
+
* `persist` reports whether the choice diverges from the stable default, so the
|
|
791
|
+
* caller knows whether to remember it as the new active pointer. `mint` is
|
|
792
|
+
* injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
|
|
793
|
+
* Pure — unit-tested.
|
|
794
|
+
* @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
|
|
795
|
+
* isNew?: boolean, mint?: (baseId: string) => string }} opts
|
|
796
|
+
* @returns {{ changeId: string, stableId: string, persist: boolean }}
|
|
797
|
+
*/
|
|
798
|
+
export function chooseChangeId({ tenant, actorKey, branch = null, active = null, isNew = false, mint = mintFreshChangeId }) {
|
|
799
|
+
const stableId = deriveChangeId(tenant, actorKey, branch);
|
|
800
|
+
const changeId = isNew ? mint(stableId) : (active || stableId);
|
|
801
|
+
const persist = isNew || (!!active && active !== stableId);
|
|
802
|
+
return { changeId, stableId, persist };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* The forge state of ONE candidate (`"open"`/`"merged"`/`"closed"`/…), read by
|
|
807
|
+
* changeId via `candidate_status`, or null when it can't be POSITIVELY determined —
|
|
808
|
+
* the candidate isn't found, carries no state, or the read throws. null ("couldn't
|
|
809
|
+
* tell") is the deliberately SAFE answer: the caller (resolveActivePointer) then
|
|
810
|
+
* behaves exactly as if the pointer were still live, so a purely-diagnostic check
|
|
811
|
+
* that can't run never blocks, changes, or crashes a submit (u17 acceptance #3).
|
|
812
|
+
* Tolerates the tool returning a bare candidate, a `{candidates:[…]}` list, or a
|
|
813
|
+
* plain array. Injectable client for tests.
|
|
814
|
+
* @param {{callTool:Function}} client
|
|
815
|
+
* @param {{ repo: string, changeId: string }} opts
|
|
816
|
+
* @returns {Promise<string|null>}
|
|
817
|
+
*/
|
|
818
|
+
export async function candidateStateFor(client, { repo, changeId }) {
|
|
819
|
+
try {
|
|
820
|
+
const r = await client.callTool("candidate_status", { repo, changeId });
|
|
821
|
+
const c = Array.isArray(r)
|
|
822
|
+
? r.find((x) => x?.changeId === changeId)
|
|
823
|
+
: Array.isArray(r?.candidates)
|
|
824
|
+
? r.candidates.find((x) => x?.changeId === changeId)
|
|
825
|
+
: r;
|
|
826
|
+
return c && typeof c.state === "string" ? c.state : null;
|
|
827
|
+
} catch {
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* u17 — before REUSING a remembered active-candidate pointer, confirm its PR is
|
|
834
|
+
* still open. The live incident this guards against: after a candidate PR merged, a
|
|
835
|
+
* plain `tot preview` reused the remembered pointer, force-pushed onto the now-DEAD
|
|
836
|
+
* candidate branch (stale old-base history), and `candidate_open` opened a NEW PR
|
|
837
|
+
* from it — inheriting a guaranteed conflict from the very first commit. When the
|
|
838
|
+
* pointer's PR has gone terminal (merged/closed) we DROP it here, so `chooseChangeId`
|
|
839
|
+
* falls back to the stable per-branch id exactly as if no pointer existed.
|
|
840
|
+
*
|
|
841
|
+
* PURELY DIAGNOSTIC — never blocks a submit over the check itself: no pointer, no
|
|
842
|
+
* repo, or a check that errors / can't positively confirm terminal all resolve to
|
|
843
|
+
* `{ active }` UNCHANGED (behave exactly as before). Only a POSITIVELY terminal
|
|
844
|
+
* state drops the pointer. When it does, `dropped` carries the old changeId + the
|
|
845
|
+
* terminal state so the caller can tell the operator and forget the on-disk pointer.
|
|
846
|
+
* Injectable client for tests.
|
|
847
|
+
* @param {{callTool:Function}} client
|
|
848
|
+
* @param {{ repo: string|null, active: string|null }} opts
|
|
849
|
+
* @returns {Promise<{ active: string|null, dropped?: { changeId: string, state: string } }>}
|
|
850
|
+
*/
|
|
851
|
+
export async function resolveActivePointer(client, { repo, active }) {
|
|
852
|
+
if (!active || !repo) return { active };
|
|
853
|
+
const state = await candidateStateFor(client, { repo, changeId: active });
|
|
854
|
+
if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state: /** @type {string} */ (state) } };
|
|
855
|
+
return { active };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
|
|
860
|
+
* SHARED `preview` ref). An explicit `--ref` always wins — the escape hatch /
|
|
861
|
+
* back-compat path (e.g. `--ref preview` reproduces the old shared-ref push for
|
|
862
|
+
* any tooling that still reads it by that literal name); otherwise it's YOUR OWN
|
|
863
|
+
* isolated candidate ref, so two developers — or the same developer on two
|
|
864
|
+
* branches — never force-push over each other or each other's preview. Pure —
|
|
865
|
+
* unit-tested.
|
|
866
|
+
* @param {{ ref?: string|null, changeId: string }} opts
|
|
867
|
+
* @returns {string}
|
|
868
|
+
*/
|
|
869
|
+
export function resolvePushRef({ ref, changeId }) {
|
|
870
|
+
return ref || candidateRefFor(changeId);
|
|
871
|
+
}
|
|
872
|
+
|
|
224
873
|
/**
|
|
225
874
|
* Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
|
|
226
875
|
* unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
|
|
@@ -233,17 +882,19 @@ export function actorKeyFor(session) {
|
|
|
233
882
|
* landed or the reconcile/compliance read-back that follows.
|
|
234
883
|
* @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
|
|
235
884
|
* @param {{ repo: string|null, changeId: string, changeSummary: {title:string, body:string[]},
|
|
236
|
-
* patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer
|
|
885
|
+
* patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer,
|
|
886
|
+
* quiet?: boolean }} opts `quiet` (--json) suppresses the human print; the same
|
|
887
|
+
* result is still returned for the caller's JSON payload.
|
|
237
888
|
*/
|
|
238
|
-
export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob }) {
|
|
889
|
+
export async function submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet = false }) {
|
|
239
890
|
if (!repo) {
|
|
240
|
-
console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
|
|
891
|
+
if (!quiet) console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
|
|
241
892
|
return null;
|
|
242
893
|
}
|
|
243
894
|
try {
|
|
244
895
|
const patch = buildFilePatch(patchEntries, readBlob);
|
|
245
896
|
if (patch.length === 0) {
|
|
246
|
-
console.log(` ~ no file changes to open a PR-backed candidate for.`);
|
|
897
|
+
if (!quiet) console.log(` ~ no file changes to open a PR-backed candidate for.`);
|
|
247
898
|
return null;
|
|
248
899
|
}
|
|
249
900
|
const result = await client.callTool("candidate_open", {
|
|
@@ -256,57 +907,244 @@ export async function submitCandidate(client, { repo, changeId, changeSummary, p
|
|
|
256
907
|
body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
|
|
257
908
|
patch,
|
|
258
909
|
});
|
|
259
|
-
reportCandidate(result, changeId);
|
|
910
|
+
reportCandidate(result, changeId, { quiet });
|
|
260
911
|
return result;
|
|
261
912
|
} catch (e) {
|
|
262
|
-
|
|
263
|
-
|
|
913
|
+
if (!quiet) {
|
|
914
|
+
console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
|
|
915
|
+
console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
|
|
916
|
+
}
|
|
264
917
|
return null;
|
|
265
918
|
}
|
|
266
919
|
}
|
|
267
920
|
|
|
268
921
|
/** Print the candidate_open result: the PR the approver reviews, or the MCP's own
|
|
269
|
-
* refusal message when it couldn't open/update one.
|
|
270
|
-
|
|
922
|
+
* refusal message when it couldn't open/update one. `quiet` (--json) suppresses it.
|
|
923
|
+
* Deliberately does NOT print `result.url` — that's the INTERNAL forge (Gitea) PR
|
|
924
|
+
* link, plumbing a developer never needs to see (DZ, 2026-08-15); the product
|
|
925
|
+
* surface is the shareable /preview/<tenant>/pr/<N> URL printed right after
|
|
926
|
+
* (shareablePrUrl). The forge URL still rides the --json payload for tooling. */
|
|
927
|
+
function reportCandidate(result, changeId, { quiet = false } = {}) {
|
|
928
|
+
if (quiet) return;
|
|
271
929
|
if (result && typeof result.prNumber === "number") {
|
|
272
930
|
console.log(`\n ✓ candidate ${result.changeId || changeId} — PR #${result.prNumber} (${result.state || "open"})`);
|
|
273
|
-
if (result.url) console.log(` ${result.url}`);
|
|
274
931
|
return;
|
|
275
932
|
}
|
|
276
933
|
const msg = result?.message || (result?.raw && String(result.raw)) || JSON.stringify(result ?? null);
|
|
277
934
|
console.log(` ~ PR-backed candidate not opened: ${msg}`);
|
|
278
935
|
}
|
|
279
936
|
|
|
280
|
-
/**
|
|
281
|
-
|
|
937
|
+
/**
|
|
938
|
+
* Build the `--json` result object (P2 item 14): candidate id, PR, head SHA,
|
|
939
|
+
* shareable preview URL, and reconcile/compliance evidence — so an
|
|
940
|
+
* LLM/automation caller can consume structured data instead of scraping
|
|
941
|
+
* human-readable stdout. `ok` mirrors the process exit code (0 ⇒ true) so a
|
|
942
|
+
* caller can branch on one field.
|
|
943
|
+
*
|
|
944
|
+
* Also carries the honest-dispatch triad (the "never dispatched" fix) so
|
|
945
|
+
* automation gets the SAME truth the human-readable path does, never a
|
|
946
|
+
* prettier lie: `dispatched` (was a webhook delivery ever observed for this
|
|
947
|
+
* commit?), `notDispatched` (the permanent-dead-end tag from
|
|
948
|
+
* pollPreviewStatus — re-running will not help), and `delivery` (the raw
|
|
949
|
+
* observability triad, or null when nothing was ever seen). All three
|
|
950
|
+
* default to their "nothing known yet" value when `status` is absent/older,
|
|
951
|
+
* so a caller can branch on `notDispatched` unconditionally without a
|
|
952
|
+
* presence check.
|
|
953
|
+
*
|
|
954
|
+
* `previewPrUrl` (P2 item — the immediate Vercel-style shareable link, see
|
|
955
|
+
* shareablePrUrl) is threaded through separately from `previewUrl`: the
|
|
956
|
+
* latter is the server-minted, reconcile-confirmed link (null until reconcile
|
|
957
|
+
* actually lands); the former is composed client-side the instant the
|
|
958
|
+
* candidate PR opens and may point at a preview that's still building.
|
|
959
|
+
* Defaults to null when no numeric PR number was known at result-build time.
|
|
960
|
+
* Pure — unit-tested.
|
|
961
|
+
* @param {{ ok: boolean, ref?: string|null, commit?: string|null, changeId?: string|null,
|
|
962
|
+
* candidate?: {prNumber?: number, number?: number, state?: string, url?: string}|null,
|
|
963
|
+
* status?: {status?: string, reconcile?: object|null, compliance?: object|null,
|
|
964
|
+
* previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
|
|
965
|
+
* notDispatched?: boolean, delivery?: object|null}|null,
|
|
966
|
+
* previewPrUrl?: string|null, error?: string|null, note?: string|null }} input
|
|
967
|
+
*/
|
|
968
|
+
export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null }) {
|
|
969
|
+
return {
|
|
970
|
+
ok,
|
|
971
|
+
ref,
|
|
972
|
+
commit,
|
|
973
|
+
changeId,
|
|
974
|
+
candidate: candidate
|
|
975
|
+
? { number: candidate.prNumber ?? candidate.number ?? null, state: candidate.state ?? null, url: candidate.url ?? null }
|
|
976
|
+
: null,
|
|
977
|
+
status: status?.status ?? null,
|
|
978
|
+
reconcile: status?.reconcile ?? null,
|
|
979
|
+
compliance: status?.compliance ?? null,
|
|
980
|
+
previewUrl: status?.previewUrl ?? null,
|
|
981
|
+
shipped: status?.shipped ?? null,
|
|
982
|
+
dispatched: status?.dispatched ?? null,
|
|
983
|
+
notDispatched: status?.notDispatched ?? false,
|
|
984
|
+
forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
|
|
985
|
+
delivery: status?.delivery ?? null,
|
|
986
|
+
previewPrUrl,
|
|
987
|
+
...(error ? { error } : {}),
|
|
988
|
+
...(note ? { note } : {}),
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** Print the `--json` result as one pretty-printed object on stdout — a no-op
|
|
993
|
+
* unless `args.json` was passed, so call sites can invoke it unconditionally. */
|
|
994
|
+
function emitJson(args, payload) {
|
|
995
|
+
if (args.json) console.log(JSON.stringify(payload, null, 2));
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* The preview flow — validate, push the preview ref, open/update the PR-backed
|
|
1000
|
+
* candidate, and stream back the reconcile/compliance/preview result. Reached by
|
|
1001
|
+
* `tot preview` and, as teaching aliases, `tot submit` / `tot deploy` (preview.mjs
|
|
1002
|
+
* wraps this and adds the verb-teaching hints). `verb` only brands the user-facing
|
|
1003
|
+
* copy (usage + the not-in-checkout error) with whatever the developer typed.
|
|
1004
|
+
* `--json` (args.json) suppresses the human-readable stdout narration in favor of
|
|
1005
|
+
* one structured result object at the end (see buildJsonResult) — stderr
|
|
1006
|
+
* diagnostics (fail(), `~ …` progress lines) still print either way.
|
|
1007
|
+
* @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
|
|
1008
|
+
*/
|
|
1009
|
+
export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
282
1010
|
const env = process.env;
|
|
283
1011
|
const args = parseArgs(argv);
|
|
284
1012
|
if (args.help) {
|
|
285
|
-
console.log(
|
|
1013
|
+
console.log(renderUsage(verb));
|
|
286
1014
|
return 0;
|
|
287
1015
|
}
|
|
1016
|
+
if (args.summary && args.summaryFile) {
|
|
1017
|
+
const msg = "--summary and --summary-file are mutually exclusive";
|
|
1018
|
+
console.error(fail(msg, "pass one or the other"));
|
|
1019
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1020
|
+
return 2;
|
|
1021
|
+
}
|
|
1022
|
+
if (args.summaryFile) {
|
|
1023
|
+
let raw;
|
|
1024
|
+
try {
|
|
1025
|
+
raw = readSummaryFileContent(args.summaryFile);
|
|
1026
|
+
} catch (e) {
|
|
1027
|
+
const msg = `couldn't read --summary-file ${args.summaryFile}: ${String(e?.message || e)}`;
|
|
1028
|
+
console.error(fail(msg, `check the path (or pass "-" to read stdin)`));
|
|
1029
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1030
|
+
return 2;
|
|
1031
|
+
}
|
|
1032
|
+
// Feeds --summary's body unchanged from here on (buildChangeSummary etc.) —
|
|
1033
|
+
// --summary-file is purely an alternate SOURCE for the same string, per the
|
|
1034
|
+
// "preserve -m/--summary unchanged" requirement.
|
|
1035
|
+
args.summary = summaryFromStructuredText(raw);
|
|
1036
|
+
}
|
|
288
1037
|
if (ctx.mode !== "checkout") {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
"tot checkout <tenant> --clone <dir> (then `cd` in, commit your work, and re-run)",
|
|
293
|
-
),
|
|
294
|
-
);
|
|
1038
|
+
const msg = `\`tot ${verb}\` runs from inside a tenant checkout`;
|
|
1039
|
+
console.error(fail(msg, "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)"));
|
|
1040
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
295
1041
|
return 2;
|
|
296
1042
|
}
|
|
297
1043
|
const workspace = ctx.workspacePath;
|
|
298
1044
|
const tenant = ctx.tenant;
|
|
299
1045
|
const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
300
1046
|
|
|
1047
|
+
// Self-heal a LEGACY checkout (unit u10): strip any token still embedded in
|
|
1048
|
+
// `origin`'s URL and install the credential helper, so this run (and every one
|
|
1049
|
+
// after) mints fresh creds through `tot` instead of relying on one that quietly
|
|
1050
|
+
// expired. Best-effort — never blocks the actual preview on a migration hiccup.
|
|
1051
|
+
try {
|
|
1052
|
+
ensureTokenlessRemote(git);
|
|
1053
|
+
} catch {
|
|
1054
|
+
/* best-effort — see above */
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Freshness preflight (unit u16) — BEFORE minting anything: is the checkout's
|
|
1058
|
+
// cached view of the base branch already behind the store? A candidate built
|
|
1059
|
+
// on a stale base is an instant, avoidable "not mergeable" the moment the
|
|
1060
|
+
// forge compares it against the real (already-advanced) base. --skip-freshness
|
|
1061
|
+
// opts out (e.g. offline/CI, or a deliberate re-run against a known-good tip).
|
|
1062
|
+
if (!args.skipFreshness) {
|
|
1063
|
+
let staleTip = null;
|
|
1064
|
+
try {
|
|
1065
|
+
staleTip = detectStaleBase(git, FRESHNESS_BASE_BRANCH);
|
|
1066
|
+
} catch {
|
|
1067
|
+
staleTip = null; // never block a submit on the preflight's OWN failure
|
|
1068
|
+
}
|
|
1069
|
+
if (staleTip) {
|
|
1070
|
+
const msg = `your checkout is behind the store — "${FRESHNESS_BASE_BRANCH}" has moved since your last sync`;
|
|
1071
|
+
console.error(
|
|
1072
|
+
fail(msg, `tot sync (fetches the latest ${FRESHNESS_BASE_BRANCH} + merges it into your branch, then re-run \`tot ${verb}\`) — or re-run with --skip-freshness to build anyway`),
|
|
1073
|
+
);
|
|
1074
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1075
|
+
return 1;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
// 0. auto-commit the known content trees (unit u2) — on a dirty tree, commit your
|
|
1081
|
+
// content edits BEFORE previewing so the preview reflects your working changes.
|
|
1082
|
+
// Only content/, public/, theme.json, .tot/ (staged by explicit path, never
|
|
1083
|
+
// `git add -A`); anything dirty outside those is refused, not silently swept in.
|
|
1084
|
+
// --no-commit opts out (preview whatever's already committed).
|
|
1085
|
+
let auto;
|
|
1086
|
+
try {
|
|
1087
|
+
auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit, workspace });
|
|
1088
|
+
} catch (e) {
|
|
1089
|
+
emitGitOp("commit", false, { command: verb, errorClass: "git_commit_failed" });
|
|
1090
|
+
const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
|
|
1091
|
+
console.error(fail(msg, "commit your content manually (git add / git commit), or re-run with --no-commit"));
|
|
1092
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1093
|
+
return 1;
|
|
1094
|
+
}
|
|
1095
|
+
if (auto.committed) emitGitOp("commit", true, { command: verb });
|
|
1096
|
+
if (auto.inProgress) {
|
|
1097
|
+
const abortCmd = abortCommandFor(auto.inProgress);
|
|
1098
|
+
const msg = `a ${auto.inProgress} is still in progress here`;
|
|
1099
|
+
console.error(
|
|
1100
|
+
fail(
|
|
1101
|
+
msg,
|
|
1102
|
+
`finish it (resolve + continue) or back out (\`${abortCmd}\`), then re-run \`tot ${verb}\` — `
|
|
1103
|
+
+ "auto-committing over an unresolved rebase/merge/cherry-pick would fold your half-resolved "
|
|
1104
|
+
+ "tree into a plain content commit and can lose whatever edit was still unresolved",
|
|
1105
|
+
),
|
|
1106
|
+
);
|
|
1107
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1108
|
+
return 1;
|
|
1109
|
+
}
|
|
1110
|
+
if (auto.refused) {
|
|
1111
|
+
console.error(
|
|
1112
|
+
fail(
|
|
1113
|
+
`${auto.unknown.length} change(s) are outside the store content trees — refusing to auto-commit`,
|
|
1114
|
+
"commit (or stash/revert) those yourself, then re-run — or use --no-commit to preview only what's already committed",
|
|
1115
|
+
) + "\n",
|
|
1116
|
+
);
|
|
1117
|
+
for (const p of auto.unknown) console.error(` ✗ out of scope: ${p}`);
|
|
1118
|
+
console.error(`\n \`tot ${verb}\` auto-commits only: ${[...KNOWN_CONTENT_TREES, ...KNOWN_CONTENT_FILES].join(", ")}`);
|
|
1119
|
+
if (auto.known.length) console.error(` (in scope, would have been committed: ${auto.known.join(", ")})`);
|
|
1120
|
+
emitJson(args, buildJsonResult({ ok: false, error: `${auto.unknown.length} change(s) outside the store content trees` }));
|
|
1121
|
+
return 1;
|
|
1122
|
+
}
|
|
1123
|
+
if (auto.committed) {
|
|
1124
|
+
console.error(
|
|
1125
|
+
`~ auto-committed ${auto.sha.slice(0, 9)} (${auto.files.length} content file(s)) — pass -m "…" to set the message, --no-commit to skip`,
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
301
1129
|
// 1. validate locally — refuse on errors.
|
|
302
1130
|
if (!args.skipValidate) {
|
|
303
1131
|
const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
|
|
1132
|
+
// Advisory but LOUD: git conflict markers must never slip past as "validated"
|
|
1133
|
+
// (the half-resolved-rebase incident). Surfaced on the ok path too — warnings
|
|
1134
|
+
// are otherwise swallowed here — but they never block the submit.
|
|
1135
|
+
const conflicts = findings.filter((f) => f.rule === "git-conflict-markers");
|
|
1136
|
+
if (conflicts.length) {
|
|
1137
|
+
console.error(`\n⚠ git conflict markers in submitted content (${conflicts.length} file(s)) — an unfinished merge/rebase?`);
|
|
1138
|
+
for (const f of conflicts) console.error(` ⚠ ${f.file} — ${f.message}`);
|
|
1139
|
+
console.error(" The preview will still build, but it will serve the broken markers. Resolve before shipping.\n");
|
|
1140
|
+
}
|
|
304
1141
|
if (!ok) {
|
|
305
1142
|
const errs = findings.filter((f) => f.level === ERROR);
|
|
306
1143
|
console.error(
|
|
307
1144
|
fail(`${errs.length} validation error(s)`, "fix these (below), or re-run with --skip-validate") + "\n",
|
|
308
1145
|
);
|
|
309
1146
|
for (const f of errs) console.error(` ✗ [${f.rule}] ${f.file} — ${f.message}`);
|
|
1147
|
+
emitJson(args, buildJsonResult({ ok: false, error: `${errs.length} validation error(s)` }));
|
|
310
1148
|
return 1;
|
|
311
1149
|
}
|
|
312
1150
|
console.error("~ validated (no errors)");
|
|
@@ -317,7 +1155,9 @@ export async function run(argv, ctx) {
|
|
|
317
1155
|
try {
|
|
318
1156
|
commit = git(["rev-parse", "HEAD"]).trim();
|
|
319
1157
|
} catch {
|
|
320
|
-
|
|
1158
|
+
const msg = "no commits here yet";
|
|
1159
|
+
console.error(fail(msg, "git add <files> && git commit -m '…', then re-run"));
|
|
1160
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
321
1161
|
return 1;
|
|
322
1162
|
}
|
|
323
1163
|
const short = commit.slice(0, 9);
|
|
@@ -329,6 +1169,9 @@ export async function run(argv, ctx) {
|
|
|
329
1169
|
// same diff (name-status, so candidate_open also knows adds/deletes/renames)
|
|
330
1170
|
// doubles as the source of the PR-backed candidate's file patch (step 2b, below)
|
|
331
1171
|
// — one git read, two consumers, so the PR always matches what's printed here.
|
|
1172
|
+
// Parameterized on `ref` (b03 — isolated candidate refs): the diff base is YOUR
|
|
1173
|
+
// candidate ref's own tracking ref, not a shared one, so the summary always reads
|
|
1174
|
+
// "vs your own last push" once the push ref is known (computed just below).
|
|
332
1175
|
const gitSafe = (cargs) => {
|
|
333
1176
|
try {
|
|
334
1177
|
return git(cargs);
|
|
@@ -336,58 +1179,206 @@ export async function run(argv, ctx) {
|
|
|
336
1179
|
return "";
|
|
337
1180
|
}
|
|
338
1181
|
};
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
1182
|
+
function buildSummaryAndPatch(ref) {
|
|
1183
|
+
const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
|
|
1184
|
+
const trackingRef = `refs/remotes/origin/${ref}`;
|
|
1185
|
+
const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
|
|
1186
|
+
? trackingRef
|
|
1187
|
+
: gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
|
|
1188
|
+
? "HEAD~1"
|
|
1189
|
+
: "";
|
|
1190
|
+
const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
|
|
1191
|
+
const patchEntries = parseNameStatus(gitSafe(statusCmd));
|
|
1192
|
+
const files = patchEntries.map((e) => e.path);
|
|
1193
|
+
const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
|
|
1194
|
+
const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
|
|
1195
|
+
return { changeSummary, patchEntries };
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// The MCP session is needed BOTH to mint a fresh forge push credential (decision
|
|
1199
|
+
// B — right below) and for the candidate/preview read-back after, so establish it
|
|
1200
|
+
// ONCE, up front, and reuse it for the whole flow.
|
|
1201
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
1202
|
+
const client = createMcpClient(baseUrl);
|
|
1203
|
+
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
1204
|
+
|
|
1205
|
+
// Branch-bound (u4) + the isolated candidate ref (b03): resolved BEFORE the push,
|
|
1206
|
+
// since the push target itself depends on it — so the raw git push and the
|
|
1207
|
+
// PR-backed candidate (step 2b, below) always land on the SAME branch. `active`/
|
|
1208
|
+
// `statePath` are local filesystem reads (candidate-state.mjs) — no session
|
|
1209
|
+
// needed — so they're available even on the no-session fallback path below.
|
|
1210
|
+
const branch = currentBranch(gitSafe);
|
|
1211
|
+
const statePath = defaultCandidateStatePath(env);
|
|
1212
|
+
let active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
|
|
1213
|
+
|
|
1214
|
+
let session;
|
|
353
1215
|
try {
|
|
354
|
-
|
|
355
|
-
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
1216
|
+
session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
356
1217
|
} catch (e) {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
1218
|
+
// Not signed in / MCP unreachable — we can't mint a fresh credential, so fall
|
|
1219
|
+
// back to pushing over the checkout's EXISTING embedded remote (pre-B behavior:
|
|
1220
|
+
// no worse than before) and skip the read-back that needs a session. The push
|
|
1221
|
+
// still lands if that embedded token is live. actorKeyFor(null) degrades to the
|
|
1222
|
+
// generic "developer" key — still isolated PER BRANCH (never the shared ref),
|
|
1223
|
+
// just not per-developer until sign-in succeeds.
|
|
1224
|
+
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, isNew: args.new });
|
|
1225
|
+
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1226
|
+
const { changeSummary } = buildSummaryAndPatch(ref);
|
|
1227
|
+
console.error(`~ pushing ${short} → ${ref} (origin)`);
|
|
1228
|
+
try {
|
|
1229
|
+
const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
|
|
1230
|
+
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
1231
|
+
emitGitOp("push", true, { command: verb });
|
|
1232
|
+
} catch (pushErr) {
|
|
1233
|
+
emitGitOp("push", false, {
|
|
1234
|
+
command: verb,
|
|
1235
|
+
errorClass: isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr) ? "forge_auth" : "git_push_failed",
|
|
1236
|
+
});
|
|
1237
|
+
const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
|
|
1238
|
+
// This is the NO-SESSION path pushing the clone-time embedded credential —
|
|
1239
|
+
// which rotation kills the moment any fresh mint happens elsewhere. An auth
|
|
1240
|
+
// failure here is therefore almost always "you're not signed in IN THIS
|
|
1241
|
+
// SHELL", not a network problem; the old remote-is-reachable hint sent a
|
|
1242
|
+
// human down the wrong path live (Trello-13075 polish).
|
|
1243
|
+
const hint = isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr)
|
|
1244
|
+
? "you're not signed in in this shell, and the checkout's embedded credential has likely been rotated — run `tot login` (check TOT_PROFILE if you use per-terminal identities), then re-run"
|
|
1245
|
+
: "check your commit and that the checkout's remote is reachable, then re-run";
|
|
1246
|
+
console.error(fail(msg, hint));
|
|
1247
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
|
|
1248
|
+
return 1;
|
|
1249
|
+
}
|
|
1250
|
+
if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
1251
|
+
printChangeSummary(changeSummary, { quiet: args.json });
|
|
1252
|
+
const note = e instanceof AuthUnavailableError
|
|
1253
|
+
? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
|
|
1254
|
+
: `couldn't reach Token of Trust for the result read-back: ${describeReadbackError(e)}`;
|
|
1255
|
+
if (!args.json) {
|
|
1256
|
+
console.log(` (${note})`);
|
|
1257
|
+
console.log(` Your push is in; the preview updates once reconcile completes.`);
|
|
1258
|
+
}
|
|
1259
|
+
emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
|
|
1260
|
+
return 0;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// u17 — before REUSING a remembered active pointer, confirm its PR is still open.
|
|
1264
|
+
// If it merged/closed we DROP it (and forget it on disk) so chooseChangeId falls
|
|
1265
|
+
// back to the stable id, rather than force-pushing onto a now-dead candidate branch
|
|
1266
|
+
// and opening a NEW PR that inherits a guaranteed conflict (the live incident this
|
|
1267
|
+
// guards against). Skipped under --new (chooseChangeId ignores `active` there
|
|
1268
|
+
// anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
|
|
1269
|
+
// Needs the tenant scope bound for candidate_status to resolve — idempotent with
|
|
1270
|
+
// the later client_switch / the fresh-mint checkoutTenant.
|
|
1271
|
+
if (active && repo && !args.new) {
|
|
1272
|
+
try {
|
|
1273
|
+
await client.callTool("client_switch", { tenant });
|
|
1274
|
+
} catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
|
|
1275
|
+
const resolved = await resolveActivePointer(client, { repo, active });
|
|
1276
|
+
if (resolved.dropped) {
|
|
1277
|
+
console.error(
|
|
1278
|
+
`~ remembered candidate ${resolved.dropped.changeId} is ${resolved.dropped.state} — dropping it and submitting fresh (a ${resolved.dropped.state} PR can't be reused).`,
|
|
1279
|
+
);
|
|
1280
|
+
try {
|
|
1281
|
+
clearActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch });
|
|
1282
|
+
} catch { /* best-effort local cleanup — a miss just re-checks next run */ }
|
|
1283
|
+
}
|
|
1284
|
+
active = resolved.active;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Which candidate (and therefore which isolated ref, b03) this submit targets —
|
|
1288
|
+
// decided now, with a real session, so the SAME id backs both the raw git push
|
|
1289
|
+
// (right below) and the PR-backed candidate (step 2b): the two never point at
|
|
1290
|
+
// different branches. See chooseChangeId's doc for the --new / active-pointer
|
|
1291
|
+
// rules.
|
|
1292
|
+
let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, isNew: args.new });
|
|
1293
|
+
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1294
|
+
const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
|
|
1295
|
+
|
|
1296
|
+
// 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
|
|
1297
|
+
// (decision B). The token baked into `.git/config` at clone time expires within
|
|
1298
|
+
// hours, so we re-mint right before the push and hand it to git ephemerally
|
|
1299
|
+
// (never persisted to `.git/config`), re-minting once on an auth failure.
|
|
1300
|
+
// `checkoutTenant(cloneDir:null)` mints without re-cloning AND client_switch()es,
|
|
1301
|
+
// binding the tenant scope the candidate/preview read-back below reads.
|
|
1302
|
+
const tag = tagFromRepoName(repo, tenant);
|
|
1303
|
+
const mintRemote = async () => {
|
|
1304
|
+
try {
|
|
1305
|
+
const res = await checkoutTenant(client, { tenant, tag, cloneDir: null, redact: redactUrl });
|
|
1306
|
+
return res.gitRemote || null;
|
|
1307
|
+
} catch (e) {
|
|
1308
|
+
console.error(
|
|
1309
|
+
`~ couldn't mint a fresh push credential (${redactUrl(String(e?.message || e))}) — using the checkout's remote`,
|
|
1310
|
+
);
|
|
1311
|
+
return null;
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
|
|
1315
|
+
console.error(`~ pushing ${short} → ${ref} (origin, fresh credential)`);
|
|
1316
|
+
try {
|
|
1317
|
+
const { out } = await pushPreviewRef(git, mintRemote, { ref });
|
|
1318
|
+
if (out && out.trim()) console.error(redactUrl(out.trim()));
|
|
1319
|
+
emitGitOp("push", true, { command: verb });
|
|
1320
|
+
} catch (e) {
|
|
1321
|
+
emitGitOp("push", false, {
|
|
1322
|
+
command: verb,
|
|
1323
|
+
errorClass: isForgeAuthError(e?.stderr || e?.message || e) ? "forge_auth" : "git_push_failed",
|
|
1324
|
+
});
|
|
1325
|
+
const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
|
|
1326
|
+
console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
|
|
1327
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
|
|
363
1328
|
return 1;
|
|
364
1329
|
}
|
|
365
|
-
console.log(`\n+ submitted ${short} to ${
|
|
366
|
-
printChangeSummary(changeSummary);
|
|
1330
|
+
if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
1331
|
+
printChangeSummary(changeSummary, { quiet: args.json });
|
|
367
1332
|
|
|
368
1333
|
// 2b + 3. open/update the PR-backed candidate, then report reconcile +
|
|
369
|
-
// compliance + preview URL from the MCP
|
|
370
|
-
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
371
|
-
const client = createMcpClient(baseUrl);
|
|
1334
|
+
// compliance + preview URL from the MCP — reusing the session established above.
|
|
372
1335
|
let progress = null;
|
|
373
1336
|
try {
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
377
|
-
// Set the active tenant so preview_status/candidate_open read the right scope
|
|
378
|
-
// (both key on the session's bound tenant/app — no tenant arg of their own).
|
|
1337
|
+
// Bind the active tenant so preview_status/candidate_open read the right scope
|
|
1338
|
+
// (idempotent — checkoutTenant already switched when the fresh mint succeeded).
|
|
379
1339
|
await client.callTool("client_switch", { tenant });
|
|
380
1340
|
|
|
381
1341
|
// 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
|
|
382
1342
|
// failure here (older MCP, VC not configured, preview-access capability) is
|
|
383
1343
|
// reported and swallowed, never blocking the preview push that already landed.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
});
|
|
1344
|
+
// `changeId`/`stableId`/`persist` were already decided above (they picked the
|
|
1345
|
+
// push ref too); if the chosen candidate turns out to be merged/closed, roll to
|
|
1346
|
+
// a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
|
|
1347
|
+
// above.)
|
|
1348
|
+
const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1349
|
+
|
|
1350
|
+
let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
1351
|
+
|
|
1352
|
+
if (candidate && isTerminalCandidateState(candidate.state)) {
|
|
1353
|
+
const rolled = mintFreshChangeId(stableId);
|
|
1354
|
+
if (!args.json) console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
|
|
1355
|
+
changeId = rolled;
|
|
1356
|
+
persist = true;
|
|
1357
|
+
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Immediate shareable URL (Vercel-style: "the URL exists before the build
|
|
1361
|
+
// does"). A non-terminal candidate with a real PR number means a preview
|
|
1362
|
+
// WILL be built at a deterministic route — so hand the developer that link
|
|
1363
|
+
// right now, before reconcile even starts, rather than making them wait for
|
|
1364
|
+
// the server-minted `previewUrl` (formatShareableUrlBlock) that only shows
|
|
1365
|
+
// up once reconcile actually completes. Honest framing: it's printed as
|
|
1366
|
+
// "building", never as "ready".
|
|
1367
|
+
const previewPrUrl =
|
|
1368
|
+
candidate && !isTerminalCandidateState(candidate.state) && typeof candidate.prNumber === "number"
|
|
1369
|
+
? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, candidate.prNumber)
|
|
1370
|
+
: null;
|
|
1371
|
+
if (previewPrUrl && !args.json) {
|
|
1372
|
+
console.log(`\n ▸ Your preview will appear at:\n ${previewPrUrl}\n (building — this link goes live once reconcile completes)`);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Remember the active candidate only on a real, non-terminal open (best-effort;
|
|
1376
|
+
// never let a state-write failure break the submit).
|
|
1377
|
+
if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
|
|
1378
|
+
try {
|
|
1379
|
+
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
1380
|
+
} catch { /* best-effort local hint — a miss just re-derives the stable id */ }
|
|
1381
|
+
}
|
|
391
1382
|
|
|
392
1383
|
let status;
|
|
393
1384
|
if (args.noWait) {
|
|
@@ -398,35 +1389,51 @@ export async function run(argv, ctx) {
|
|
|
398
1389
|
// as live, not as a growing wall of "(1)…(8)" lines. The counter keeps
|
|
399
1390
|
// ticking on its own 90ms timer even while a single poll long-polls for
|
|
400
1391
|
// waitMs, so elapsed time is real wall-clock, not the attempt count.
|
|
1392
|
+
// --json is for automation: no interactive spinner (stays silent — the
|
|
1393
|
+
// JSON result carries the same status at the end).
|
|
1394
|
+
// Honest opening label (the incident this fixes: a job may never actually
|
|
1395
|
+
// get dispatched — see pollPreviewStatus's notDispatched short-circuit — so
|
|
1396
|
+
// the INITIAL text must not assert a reconcile job exists before one has
|
|
1397
|
+
// been observed). Once a tick confirms `s.dispatched === true` the 45s
|
|
1398
|
+
// stage text ("still reconciling…") IS truthful and is left as-is below.
|
|
401
1399
|
let phase = "reconcile";
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
1400
|
+
if (!args.json) {
|
|
1401
|
+
progress = startProgress(`waiting for reconcile of ${short}…`, {
|
|
1402
|
+
stages: [{ afterMs: 45_000, text: `still reconciling ${short}… (larger changes take longer)` }],
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
405
1405
|
status = await pollPreviewStatus(client, commit, {
|
|
406
1406
|
...(args.watch ? WATCH_POLL : DEFAULT_POLL),
|
|
407
1407
|
onTick: (s) => {
|
|
408
1408
|
// Reconcile is done but we're still waiting on a ship decision (--watch):
|
|
409
1409
|
// swap the label so the single line reflects the new phase, timer resets.
|
|
410
|
-
if (s.status === "reconciled" && !s.shipped && phase !== "ship") {
|
|
1410
|
+
if (!args.json && s.status === "reconciled" && !s.shipped && phase !== "ship") {
|
|
411
1411
|
phase = "ship";
|
|
412
1412
|
progress.stop();
|
|
413
1413
|
progress = startProgress(`reconciled ${short} — waiting for a ship decision…`);
|
|
414
1414
|
}
|
|
415
1415
|
},
|
|
416
1416
|
});
|
|
417
|
-
progress
|
|
418
|
-
|
|
1417
|
+
if (progress) {
|
|
1418
|
+
progress.stop();
|
|
1419
|
+
progress = null;
|
|
1420
|
+
}
|
|
419
1421
|
}
|
|
420
|
-
|
|
1422
|
+
// --json also skips the browser auto-open (open: !args.noOpen && !args.json)
|
|
1423
|
+
// — automation doesn't want a browser popping up.
|
|
1424
|
+
reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit, ref, verb, noChanges: patchEntries.length === 0 });
|
|
1425
|
+
emitJson(args, buildJsonResult({ ok: status?.status !== "failed", ref, commit, changeId, candidate, status, previewPrUrl }));
|
|
421
1426
|
return status?.status === "failed" ? 1 : 0;
|
|
422
1427
|
} catch (e) {
|
|
423
1428
|
progress?.stop();
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1429
|
+
const note = e instanceof AuthUnavailableError
|
|
1430
|
+
? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
|
|
1431
|
+
: `reconcile is running — the result read-back isn't available yet: ${describeReadbackError(e)}`;
|
|
1432
|
+
if (!args.json) {
|
|
1433
|
+
console.log(` (${note})`);
|
|
1434
|
+
console.log(` Your push is in; the preview updates once reconcile completes.`);
|
|
428
1435
|
}
|
|
429
|
-
|
|
1436
|
+
emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
|
|
430
1437
|
return 0;
|
|
431
1438
|
}
|
|
432
1439
|
}
|
|
@@ -434,20 +1441,31 @@ export async function run(argv, ctx) {
|
|
|
434
1441
|
/**
|
|
435
1442
|
* Normalize a `preview_status` tool response to the contract shape the CLI reports
|
|
436
1443
|
* on: { status, reconcile:{ok,errors}, compliance:{verdict,detail}, previewUrl,
|
|
437
|
-
* shipped }. A live tool always returns status
|
|
438
|
-
* else (an older MCP without the tool's flat
|
|
439
|
-
* CLI degrades visibly instead of pretending
|
|
440
|
-
* null until change_accept ships this exact commit.
|
|
1444
|
+
* shipped, delivery, dispatched }. A live tool always returns status
|
|
1445
|
+
* pending|reconciled|failed; anything else (an older MCP without the tool's flat
|
|
1446
|
+
* fields) normalizes to "unknown" so the CLI degrades visibly instead of pretending
|
|
1447
|
+
* it reconciled. `shipped` (E1b) is null until change_accept ships this exact commit.
|
|
1448
|
+
*
|
|
1449
|
+
* `delivery` is the MCP's reconcile-observability triad for the Gitea webhook that
|
|
1450
|
+
* fired on this commit ({ target, actual, evidence, drift? }) — or null when NO
|
|
1451
|
+
* webhook delivery was observed for this (tenant, commit). `dispatched` distills that
|
|
1452
|
+
* to a boolean: a `pending` status with `dispatched === false` means the reconcile
|
|
1453
|
+
* was NEVER DISPATCHED (no webhook fired — e.g. an unregistered hook, or a commit
|
|
1454
|
+
* read under a different tenant scope than it was pushed to), which is a permanent
|
|
1455
|
+
* dead-end the CLI must not report as "still reconciling". Pure — unit-tested.
|
|
441
1456
|
*/
|
|
442
1457
|
export function normalizePreviewStatus(r) {
|
|
443
1458
|
const status = r?.status;
|
|
444
1459
|
const known = status === "pending" || status === "reconciled" || status === "failed";
|
|
1460
|
+
const delivery = r?.delivery ?? null;
|
|
445
1461
|
return {
|
|
446
1462
|
status: known ? status : "unknown",
|
|
447
1463
|
reconcile: r?.reconcile ?? null,
|
|
448
1464
|
compliance: r?.compliance ?? null,
|
|
449
1465
|
previewUrl: r?.previewUrl ?? null,
|
|
450
1466
|
shipped: r?.shipped ?? null,
|
|
1467
|
+
delivery,
|
|
1468
|
+
dispatched: delivery != null,
|
|
451
1469
|
raw: r,
|
|
452
1470
|
};
|
|
453
1471
|
}
|
|
@@ -464,29 +1482,67 @@ export function normalizePreviewStatus(r) {
|
|
|
464
1482
|
* the pre-E2 fixed-interval poll — no version check needed, the fallback is
|
|
465
1483
|
* automatic. Stops as soon as status resolves to "failed"/"unknown" (a failed
|
|
466
1484
|
* reconcile can't ship), or resolves to "reconciled" AND (not untilShipped, or
|
|
467
|
-
* already shipped).
|
|
1485
|
+
* already shipped).
|
|
1486
|
+
*
|
|
1487
|
+
* NEVER-DISPATCHED short-circuit (the "still reconciling forever" fix): a `pending`
|
|
1488
|
+
* status with NO delivery ever observed for this commit means no reconcile job was
|
|
1489
|
+
* ever dispatched (unregistered webhook, or a tenant-scope mismatch on the read) —
|
|
1490
|
+
* it will never resolve. Rather than walk the whole (~8 min under --watch) budget
|
|
1491
|
+
* lying about progress, once we're past a short startup grace (`notDispatchedGraceMs`
|
|
1492
|
+
* — long enough for a real delivery record to land after the push) with the delivery
|
|
1493
|
+
* still absent, we stop and return the honest state tagged `notDispatched: true`. If
|
|
1494
|
+
* a delivery IS seen we keep polling as before (dispatched, just slow), and a plain
|
|
1495
|
+
* timeout while still pending is tagged `notDispatched` only when a delivery was never
|
|
1496
|
+
* observed. Injectable delay/attempts/waitMs/grace + a `now` clock for tests.
|
|
468
1497
|
* @param {{callTool:Function}} client
|
|
469
1498
|
* @param {string} commit
|
|
470
|
-
* @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
|
|
1499
|
+
* @param {{ attempts?: number, delayMs?: number, waitMs?: number, untilShipped?: boolean,
|
|
1500
|
+
* notDispatchedGraceMs?: number, now?: () => number, onTick?: (s:object,i:number)=>void }} [opts]
|
|
471
1501
|
*/
|
|
472
1502
|
export async function pollPreviewStatus(
|
|
473
1503
|
client,
|
|
474
1504
|
commit,
|
|
475
|
-
{ attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, onTick } = {},
|
|
1505
|
+
{ attempts = 8, delayMs = 2500, waitMs = delayMs, untilShipped = false, notDispatchedGraceMs = 15_000, now = Date.now, onTick } = {},
|
|
476
1506
|
) {
|
|
477
1507
|
let last = null;
|
|
1508
|
+
let everDispatched = false;
|
|
1509
|
+
const pollStart = now();
|
|
478
1510
|
for (let i = 0; i < attempts; i++) {
|
|
479
|
-
const startedAt =
|
|
1511
|
+
const startedAt = now();
|
|
480
1512
|
const args = waitMs ? { commit, waitMs } : { commit };
|
|
481
1513
|
last = normalizePreviewStatus(await client.callTool("preview_status", args));
|
|
1514
|
+
if (last.dispatched) everDispatched = true;
|
|
1515
|
+
last.everDispatched = everDispatched;
|
|
482
1516
|
if (onTick) onTick(last, i);
|
|
1517
|
+
// Terminally-failed forward: the delivery record settled with forwarded:false
|
|
1518
|
+
// (and it isn't the at-receipt `pending` marker) — the control plane could not
|
|
1519
|
+
// deliver this commit to the reconciler, and polling longer cannot change that.
|
|
1520
|
+
// Only a NEW push produces a new delivery. Stop and say so (Trello-13075
|
|
1521
|
+
// honesty discipline: never spin on a state that cannot progress).
|
|
1522
|
+
if (
|
|
1523
|
+
last.status === "pending" &&
|
|
1524
|
+
last.delivery?.actual &&
|
|
1525
|
+
last.delivery.actual.forwarded === false &&
|
|
1526
|
+
!last.delivery.actual.pending
|
|
1527
|
+
) {
|
|
1528
|
+
return { ...last, forwardFailed: true };
|
|
1529
|
+
}
|
|
1530
|
+
// Never-dispatched dead-end: still pending, no delivery has EVER been observed
|
|
1531
|
+
// for this commit, and we're past the startup grace — the reconcile will never
|
|
1532
|
+
// arrive. Return honestly instead of continuing to show "still reconciling".
|
|
1533
|
+
if (last.status === "pending" && !everDispatched && now() - pollStart >= notDispatchedGraceMs) {
|
|
1534
|
+
return { ...last, notDispatched: true };
|
|
1535
|
+
}
|
|
483
1536
|
const stillWatchingForShip = untilShipped && last.status === "reconciled" && !last.shipped;
|
|
484
1537
|
if (last.status !== "pending" && !stillWatchingForShip) return last;
|
|
485
1538
|
if (i < attempts - 1) {
|
|
486
|
-
const remaining = delayMs - (
|
|
1539
|
+
const remaining = delayMs - (now() - startedAt);
|
|
487
1540
|
if (remaining > 0) await delay(remaining);
|
|
488
1541
|
}
|
|
489
1542
|
}
|
|
1543
|
+
// Budget exhausted. A still-pending result that never saw a delivery is a
|
|
1544
|
+
// never-dispatched dead-end (honest), not "still working".
|
|
1545
|
+
if (last) return { ...last, everDispatched, notDispatched: last.status === "pending" && !everDispatched };
|
|
490
1546
|
return last;
|
|
491
1547
|
}
|
|
492
1548
|
|
|
@@ -506,43 +1562,216 @@ function reportComplianceCheck(c) {
|
|
|
506
1562
|
if (c.hint) console.log(` → fix: ${c.hint}`);
|
|
507
1563
|
}
|
|
508
1564
|
|
|
1565
|
+
/**
|
|
1566
|
+
* The IMMEDIATE shareable preview URL (Vercel-style: "the URL exists before the
|
|
1567
|
+
* build does") — composed client-side, deterministically, from the tenant + PR
|
|
1568
|
+
* number the candidate_open call just returned, so a developer gets a link to
|
|
1569
|
+
* paste to a reviewer the INSTANT the candidate opens, not minutes later once
|
|
1570
|
+
* reconcile finishes and the MCP mints `previewUrl` server-side (that's
|
|
1571
|
+
* formatShareableUrlBlock's job, above — the two are deliberately redundant:
|
|
1572
|
+
* this one is available immediately but "building", that one is authoritative
|
|
1573
|
+
* once reconcile actually lands). Same route shape as the server-minted one
|
|
1574
|
+
* (`/preview/<tenant>/pr/<N>`) by construction — see
|
|
1575
|
+
* docs/architecture/preview-candidate-workflow.md — so the link doesn't change
|
|
1576
|
+
* out from under the reviewer once the build completes; it just starts
|
|
1577
|
+
* resolving.
|
|
1578
|
+
* Trims a trailing slash off `base` so a `TOT_STOREFRONT_URL` set WITH or
|
|
1579
|
+
* without one composes identically. Pure — unit-tested.
|
|
1580
|
+
* @param {string} base storefront origin, e.g. https://storefront.tokenoftrust.store
|
|
1581
|
+
* @param {string} tenant
|
|
1582
|
+
* @param {number} prNumber
|
|
1583
|
+
* @returns {string}
|
|
1584
|
+
*/
|
|
1585
|
+
export function shareablePrUrl(base, tenant, prNumber) {
|
|
1586
|
+
return `${String(base).replace(/\/+$/, "")}/preview/${tenant}/pr/${prNumber}`;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Humanize a result-read-back failure. MCP auth errors arrive as a JSON blob
|
|
1591
|
+
* whose `data.self_repair` carries a summary + step list — dumping that raw
|
|
1592
|
+
* into the terminal (observed live: a wall of escaped JSON mid-submit) buries
|
|
1593
|
+
* the one thing the developer needs: sign in again. Detect that shape and
|
|
1594
|
+
* reduce it to the summary's first sentence + the concrete next step; anything
|
|
1595
|
+
* else passes through unchanged. Pure — unit-tested.
|
|
1596
|
+
* @param {unknown} e
|
|
1597
|
+
* @returns {string}
|
|
1598
|
+
*/
|
|
1599
|
+
export function describeReadbackError(e) {
|
|
1600
|
+
const msg = String(/** @type {any} */ (e)?.message || e || "");
|
|
1601
|
+
const jsonStart = msg.indexOf("{");
|
|
1602
|
+
if (jsonStart >= 0 && msg.includes("self_repair")) {
|
|
1603
|
+
try {
|
|
1604
|
+
const body = JSON.parse(msg.slice(jsonStart));
|
|
1605
|
+
const repair = body?.data?.self_repair;
|
|
1606
|
+
const summaryFirst = String(repair?.summary || body?.message || "").split(/(?<=\.)\s/)[0];
|
|
1607
|
+
if (summaryFirst) {
|
|
1608
|
+
return `${summaryFirst} Next: run \`tot login\` in this shell (check TOT_PROFILE), then re-run.`;
|
|
1609
|
+
}
|
|
1610
|
+
} catch {
|
|
1611
|
+
// Not the shape we thought — fall through to the raw message.
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
return msg;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* Build the printed lines for the headline "share this with your reviewer" block —
|
|
1619
|
+
* the whole point of U14: on a successful preview, the SHAREABLE deep link
|
|
1620
|
+
* (`https://storefront.tokenoftrust.store/preview/<tenant>/pr/<N>`, built server-side
|
|
1621
|
+
* by the reconcile report and threaded through as `previewUrl`) is what a developer
|
|
1622
|
+
* hands to a teammate, NOT the internal Gitea PR url `reportCandidate` prints earlier
|
|
1623
|
+
* in the flow (step 2b) — that one stays as-is, secondary, for the developer's own
|
|
1624
|
+
* reference. Labeled prominently + paired with an honest auth caveat: the share
|
|
1625
|
+
* target is a TEAMMATE WITH STORE ACCESS (member/staff of the tenant), never a
|
|
1626
|
+
* public/anonymous link.
|
|
1627
|
+
*
|
|
1628
|
+
* Degrades gracefully when `previewUrl` isn't (yet) on the status result — an older
|
|
1629
|
+
* MCP, or a reconcile that hasn't finished minting it — with a note instead of a
|
|
1630
|
+
* crash or a silent blank. Pure — unit-tested.
|
|
1631
|
+
* @param {{ previewUrl?: string|null, status?: string }|null} s
|
|
1632
|
+
* @param {string} tenant
|
|
1633
|
+
* @returns {string[]}
|
|
1634
|
+
*/
|
|
1635
|
+
export function formatShareableUrlBlock(s, tenant) {
|
|
1636
|
+
if (s?.previewUrl) {
|
|
1637
|
+
return [
|
|
1638
|
+
`\n ✓ Preview ready — share this with your reviewer:`,
|
|
1639
|
+
` ${s.previewUrl}`,
|
|
1640
|
+
` ℹ your reviewer needs store access (a member/staff of ${tenant}) to view it — it's not a public link.`,
|
|
1641
|
+
];
|
|
1642
|
+
}
|
|
1643
|
+
if (s?.status === "reconciled") {
|
|
1644
|
+
return [`\n ~ reconciled, but no shareable preview URL yet — it'll show up here once available.`];
|
|
1645
|
+
}
|
|
1646
|
+
return [];
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* The honest "no reconcile job was dispatched" block — printed when a preview stays
|
|
1651
|
+
* `pending` with no webhook delivery ever observed for the commit (pollPreviewStatus
|
|
1652
|
+
* tagged it `notDispatched`). This replaces the old "reconcile still running — check
|
|
1653
|
+
* back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
|
|
1654
|
+
* what actually happened and what to do, and never recommend another submit. Pure —
|
|
1655
|
+
* unit-tested. `verb` brands the copy with whatever the developer typed.
|
|
1656
|
+
* @param {{ commit?: string|null, ref?: string|null, noChanges?: boolean }} ctx
|
|
1657
|
+
* @param {string} tenant @param {string} [verb]
|
|
1658
|
+
* @returns {string[]}
|
|
1659
|
+
*/
|
|
1660
|
+
export function formatNotDispatchedBlock({ commit = null, ref = null, noChanges = false } = {}, tenant, verb = "preview") {
|
|
1661
|
+
const short = commit ? commit.slice(0, 9) : "(unknown commit)";
|
|
1662
|
+
// Empty-diff cause FIRST when we know it applies (live-testing finding: an
|
|
1663
|
+
// empty candidate submit structurally CANNOT build — no diff → no candidate PR
|
|
1664
|
+
// → no pull_request webhook — and blaming webhooks/scope for it sent a human
|
|
1665
|
+
// down two wrong debugging paths).
|
|
1666
|
+
const causes = [
|
|
1667
|
+
...(noChanges
|
|
1668
|
+
? [` • your submit contained NO content changes — a candidate with no diff opens no PR and builds nothing (make an edit, or move the shared ref: \`tot ${verb} --ref preview\`),`]
|
|
1669
|
+
: []),
|
|
1670
|
+
` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
|
|
1671
|
+
` • your session is scoped to a different store than the one you pushed.`,
|
|
1672
|
+
];
|
|
1673
|
+
return [
|
|
1674
|
+
`\n ⚠ No reconcile was dispatched for ${short} on ${tenant}.`,
|
|
1675
|
+
` Your push landed${ref ? ` on ${ref}` : ""}, but nothing picked it up to build a preview —`,
|
|
1676
|
+
` re-running \`tot ${verb}\` will NOT change that. This usually means one of:`,
|
|
1677
|
+
...causes,
|
|
1678
|
+
` Next:`,
|
|
1679
|
+
` • \`tot grants\` — confirm ${tenant} is active for you;`,
|
|
1680
|
+
` • check the preview dashboard for ${tenant} (it will read "Last reconcile: never" until a job runs);`,
|
|
1681
|
+
` • if it stays "never", share this with support: commit ${short}, tenant ${tenant}${ref ? `, ref ${ref}` : ""}.`,
|
|
1682
|
+
];
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* The honest "the delivery FAILED to forward" block — the delivery record settled
|
|
1687
|
+
* `forwarded: false` (not the at-receipt pending marker), so the control plane
|
|
1688
|
+
* could not deliver this commit to the reconciler; polling longer cannot change
|
|
1689
|
+
* that, and ONLY a new push produces a new delivery. Distinct from
|
|
1690
|
+
* `formatNotDispatchedBlock` (nothing was ever dispatched) — here the plumbing
|
|
1691
|
+
* fired and died in transit, so the recovery differs. Pure — unit-tested.
|
|
1692
|
+
* @param {{ commit?: string|null }} ctx @param {string} tenant
|
|
1693
|
+
* @returns {string[]}
|
|
1694
|
+
*/
|
|
1695
|
+
export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
|
|
1696
|
+
const short = commit ? commit.slice(0, 9) : "(unknown commit)";
|
|
1697
|
+
return [
|
|
1698
|
+
`\n ⚠ The reconcile delivery for ${short} on ${tenant} FAILED in transit (network/timeout at the control plane).`,
|
|
1699
|
+
` Waiting longer will not help — only a NEW push produces a new delivery.`,
|
|
1700
|
+
` Next: commit again (an empty commit works: git commit --allow-empty -m retry) and re-push;`,
|
|
1701
|
+
` if it fails the same way twice, share this with support: commit ${short}, tenant ${tenant}.`,
|
|
1702
|
+
];
|
|
1703
|
+
}
|
|
1704
|
+
|
|
509
1705
|
/**
|
|
510
1706
|
* Print the reconcile/compliance/preview result and, on a clean reconcile with a
|
|
511
|
-
* preview URL, open it in the browser (unless opts.open === false).
|
|
1707
|
+
* preview URL, open it in the browser (unless opts.open === false). `quiet`
|
|
1708
|
+
* (--json) suppresses ALL printing here — the browser open still runs unless
|
|
1709
|
+
* the caller also passes `open: false` (run() passes `open: false` under
|
|
1710
|
+
* --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
|
|
1711
|
+
* feed the honest never-dispatched block.
|
|
1712
|
+
*/
|
|
1713
|
+
/**
|
|
1714
|
+
* @param {any} s @param {string} tenant
|
|
1715
|
+
* @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
|
|
512
1716
|
*/
|
|
513
|
-
function reportStatus(s, tenant, { open = true } = {}) {
|
|
1717
|
+
function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
|
|
514
1718
|
if (!s || s.status === "unknown") {
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
`
|
|
518
|
-
|
|
1719
|
+
if (!quiet) {
|
|
1720
|
+
console.log(
|
|
1721
|
+
` (this MCP doesn't return the per-commit reconcile result yet — your push is in;\n` +
|
|
1722
|
+
` the preview updates once reconcile runs. Check the preview dashboard.)`,
|
|
1723
|
+
);
|
|
1724
|
+
}
|
|
519
1725
|
return;
|
|
520
1726
|
}
|
|
521
|
-
|
|
522
|
-
|
|
1727
|
+
// Terminally-failed forward — the delivery fired and died in transit; a re-push
|
|
1728
|
+
// (new delivery) is the only recovery. Checked BEFORE notDispatched: a settled
|
|
1729
|
+
// failed forward IS a dispatch, just a doomed one.
|
|
1730
|
+
if (s.forwardFailed) {
|
|
1731
|
+
if (!quiet) for (const line of formatForwardFailedBlock({ commit }, tenant)) console.log(line);
|
|
523
1732
|
return;
|
|
524
1733
|
}
|
|
525
|
-
|
|
526
|
-
if (
|
|
527
|
-
if (
|
|
528
|
-
|
|
529
|
-
console.log(` ✗ reconcile failed:`);
|
|
530
|
-
for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
if (s.compliance?.verdict) {
|
|
534
|
-
console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
|
|
535
|
-
for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
|
|
1734
|
+
// Never-dispatched dead-end — the honest replacement for false "still reconciling".
|
|
1735
|
+
if (s.notDispatched) {
|
|
1736
|
+
if (!quiet) for (const line of formatNotDispatchedBlock({ commit, ref, noChanges }, tenant, verb)) console.log(line);
|
|
1737
|
+
return;
|
|
536
1738
|
}
|
|
537
|
-
if (s.
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1739
|
+
if (s.status === "pending") {
|
|
1740
|
+
if (!quiet) {
|
|
1741
|
+
// Dispatched but not yet reported (real slow reconcile) vs. no job seen yet on
|
|
1742
|
+
// a --no-wait snapshot — say which, and never claim progress we can't see.
|
|
1743
|
+
if (s.dispatched === false) {
|
|
1744
|
+
console.log(` no reconcile job seen yet for ${tenant} — if it doesn't appear shortly, run \`tot grants\` / check the dashboard.`);
|
|
1745
|
+
} else {
|
|
1746
|
+
console.log(` reconcile still running for ${tenant} — check back shortly (re-run \`tot submit --no-wait\`).`);
|
|
1747
|
+
if (s.delivery?.drift) {
|
|
1748
|
+
console.log(` ⚠ a reconcile report exists for a DIFFERENT commit than you pushed — possible tenant-scope mismatch (\`tot grants\` to check your active store).`);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return;
|
|
541
1753
|
}
|
|
542
|
-
if (
|
|
543
|
-
|
|
544
|
-
if (
|
|
545
|
-
console.log(
|
|
1754
|
+
if (!quiet) {
|
|
1755
|
+
const rc = s.reconcile;
|
|
1756
|
+
if (rc) {
|
|
1757
|
+
if (rc.ok) console.log(` ✓ reconcile ok`);
|
|
1758
|
+
else {
|
|
1759
|
+
console.log(` ✗ reconcile failed:`);
|
|
1760
|
+
for (const e of rc.errors ?? []) console.log(` [${e.rule ?? "?"}] ${e.message ?? ""}`);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
if (s.compliance?.verdict) {
|
|
1764
|
+
console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
|
|
1765
|
+
for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
|
|
546
1766
|
}
|
|
1767
|
+
if (s.shipped) {
|
|
1768
|
+
console.log(`\n ✓ shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
|
|
1769
|
+
} else if (s.status === "reconciled") {
|
|
1770
|
+
console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
|
|
1771
|
+
}
|
|
1772
|
+
for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
|
|
1773
|
+
}
|
|
1774
|
+
if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
|
|
1775
|
+
if (!quiet) console.log(" (opened in your browser)");
|
|
547
1776
|
}
|
|
548
1777
|
}
|