@tokenoftrust/cli 2.0.11 → 2.0.12
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/package.json +1 -1
- package/src/candidate-state.mjs +52 -27
- package/src/commands/submit.mjs +140 -200
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.12",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/candidate-state.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* updates, per forge repo.
|
|
4
4
|
*
|
|
5
5
|
* `tot submit` is idempotent on a STABLE changeId (`deriveChangeId`) so a re-submit
|
|
6
|
-
* updates the same PR by default
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* updates the same PR by default. Every chosen identity is persisted before its
|
|
7
|
+
* ref is pushed, which makes a failed push or MCP registration retry the same ref.
|
|
8
|
+
* The stored pointer is especially important when the chosen id diverges from the
|
|
9
|
+
* stable default:
|
|
9
10
|
*
|
|
10
11
|
* - `tot submit --fork-candidate` forks a fresh candidate and remembers it here, so the
|
|
11
12
|
* NEXT plain `tot submit` keeps updating the NEW PR (like pushing more commits
|
|
@@ -18,14 +19,13 @@
|
|
|
18
19
|
* candidates): a different MCP, repo, OR non-default git branch is a different
|
|
19
20
|
* candidate namespace, so a feature branch gets its OWN candidate PR instead of
|
|
20
21
|
* fighting main's over the same handle. The default branch deliberately keeps the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* discipline as last-tenant.mjs (0600 in a 0700 dir, write-tmp-then-rename).
|
|
22
|
+
* branch-less key. Non-default branches never inherit that pointer. Updates hold an
|
|
23
|
+
* exclusive lock across read-modify-write so concurrent submits cannot lose either
|
|
24
|
+
* identity. Files use mode 0600 in a 0700 directory.
|
|
25
25
|
* Dependency-free (node:fs/os/path). `TOT_HOME` overrides home (tests).
|
|
26
26
|
*/
|
|
27
27
|
import {
|
|
28
|
-
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync,
|
|
28
|
+
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, rmdirSync, statSync,
|
|
29
29
|
} from "node:fs";
|
|
30
30
|
import { homedir } from "node:os";
|
|
31
31
|
import { join, dirname } from "node:path";
|
|
@@ -79,45 +79,70 @@ function readMap(filePath) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
function writeMap(filePath, map) {
|
|
82
|
-
|
|
83
|
-
const tmp = `${filePath}.tmp`;
|
|
82
|
+
const tmp = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
84
83
|
writeFileSync(tmp, `${JSON.stringify(map, null, 2)}\n`, { mode: 0o600 });
|
|
85
84
|
renameSync(tmp, filePath);
|
|
86
85
|
chmodSync(filePath, 0o600);
|
|
87
86
|
}
|
|
88
87
|
|
|
88
|
+
const LOCK_RETRY_MS = 10;
|
|
89
|
+
const LOCK_ATTEMPTS = 200;
|
|
90
|
+
const STALE_LOCK_MS = 30_000;
|
|
91
|
+
const lockWait = new Int32Array(new SharedArrayBuffer(4));
|
|
92
|
+
|
|
93
|
+
function updateMap(filePath, mutate) {
|
|
94
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
95
|
+
const lockPath = `${filePath}.lock`;
|
|
96
|
+
let locked = false;
|
|
97
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS && !locked; attempt += 1) {
|
|
98
|
+
try {
|
|
99
|
+
mkdirSync(lockPath, { mode: 0o700 });
|
|
100
|
+
locked = true;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error?.code !== "EEXIST") throw error;
|
|
103
|
+
try {
|
|
104
|
+
if (Date.now() - statSync(lockPath).mtimeMs > STALE_LOCK_MS) rmdirSync(lockPath);
|
|
105
|
+
} catch (staleError) {
|
|
106
|
+
if (staleError?.code !== "ENOENT" && staleError?.code !== "ENOTEMPTY") throw staleError;
|
|
107
|
+
}
|
|
108
|
+
if (!locked) Atomics.wait(lockWait, 0, 0, LOCK_RETRY_MS);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!locked) throw new Error(`Candidate state is busy: ${lockPath}`);
|
|
112
|
+
try {
|
|
113
|
+
const map = readMap(filePath);
|
|
114
|
+
if (mutate(map)) writeMap(filePath, map);
|
|
115
|
+
} finally {
|
|
116
|
+
rmdirSync(lockPath);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
89
120
|
/**
|
|
90
121
|
* The remembered active changeId for `(mcpUrl, repo, branch)`, or null when there
|
|
91
122
|
* isn't one (absent/unreadable/malformed) — a miss means "use the stable default".
|
|
92
|
-
*
|
|
93
|
-
* branch-less key so state written before the rekey (or by the default branch)
|
|
94
|
-
* isn't orphaned. Never throws.
|
|
123
|
+
* Non-default branches never read the default branch's pointer. Never throws.
|
|
95
124
|
*/
|
|
96
125
|
export function readActiveChangeId(filePath, { mcpUrl, repo, branch }) {
|
|
97
126
|
const map = readMap(filePath);
|
|
98
|
-
|
|
99
|
-
if (primary) return primary;
|
|
100
|
-
// Legacy fallback: a branch-scoped miss reads the old branch-less key (a no-op
|
|
101
|
-
// when we're already on the default branch, which IS the legacy key).
|
|
102
|
-
if (!isDefaultBranch(branch)) return recordChangeId(map[legacyStateKey(mcpUrl, repo)]);
|
|
103
|
-
return null;
|
|
127
|
+
return recordChangeId(map[stateKey(mcpUrl, repo, branch)]);
|
|
104
128
|
}
|
|
105
129
|
|
|
106
130
|
/** Remember `changeId` as the active candidate for `(mcpUrl, repo, branch)`, atomically. */
|
|
107
131
|
export function writeActiveChangeId(filePath, { mcpUrl, repo, branch, changeId }) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
132
|
+
updateMap(filePath, (map) => {
|
|
133
|
+
map[stateKey(mcpUrl, repo, branch)] = { changeId, updatedAt: Date.now() };
|
|
134
|
+
return true;
|
|
135
|
+
});
|
|
111
136
|
}
|
|
112
137
|
|
|
113
138
|
/** Forget the active candidate for `(mcpUrl, repo, branch)` (e.g. after closing it). */
|
|
114
139
|
export function clearActiveChangeId(filePath, { mcpUrl, repo, branch }) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
140
|
+
updateMap(filePath, (map) => {
|
|
141
|
+
const key = stateKey(mcpUrl, repo, branch);
|
|
142
|
+
if (!(key in map)) return false;
|
|
118
143
|
delete map[key];
|
|
119
|
-
|
|
120
|
-
}
|
|
144
|
+
return true;
|
|
145
|
+
});
|
|
121
146
|
}
|
|
122
147
|
|
|
123
148
|
/**
|
package/src/commands/submit.mjs
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
* From inside a tenant checkout:
|
|
5
5
|
* 1. validate locally and refuse on errors (fail fast before anything leaves the machine),
|
|
6
6
|
* 2. push your committed work to YOUR OWN isolated candidate ref — `candidate/<changeId>`
|
|
7
|
-
* (b03 — no shared `preview` ref is ever force-pushed
|
|
8
|
-
*
|
|
9
|
-
* 2b. open/update a PR-BACKED CANDIDATE for the same committed diff (g1b's
|
|
7
|
+
* (b03 — no shared `preview` ref is ever force-pushed) — which triggers reconcile,
|
|
8
|
+
* 2b. register that exact ref + asserted HEAD as a PR-BACKED CANDIDATE (g1b's
|
|
10
9
|
* `candidate_open`, unit c1 — the local-dev-loop half of the "PR-Backed Hosted
|
|
11
10
|
* Review Loop" milestone, symmetric with the hosted s6 draft-as-PR path), and
|
|
12
11
|
* print the resulting changeId/PR number/URL,
|
|
@@ -14,14 +13,11 @@
|
|
|
14
13
|
*
|
|
15
14
|
* This is submit-for-PREVIEW, not ship-to-live (`change_accept`/`candidate_accept` /
|
|
16
15
|
* a future `tot ship` is the separate ship gate — this command is submit-only, never
|
|
17
|
-
* accept/reject). Step 2b
|
|
18
|
-
*
|
|
19
|
-
* refusal is REQUIRED and exits non-zero with identity-recovery guidance. The preview
|
|
20
|
-
* push may already have landed, but no unaudited PR is created. Step 3 calls
|
|
16
|
+
* accept/reject). Step 2b fails closed: a push without an exact, verified PR registration
|
|
17
|
+
* exits non-zero and keeps its stable retry identity. Step 3 calls
|
|
21
18
|
* the MCP `preview_status` read-back: given the commit just pushed it returns
|
|
22
19
|
* { status, reconcile, compliance, previewUrl } and we poll it while reconcile is
|
|
23
|
-
* pending.
|
|
24
|
-
* pushes and reports "reconcile pending" — degrading visibly, never a crash.
|
|
20
|
+
* pending. A read-back failure after the candidate exists is reported separately.
|
|
25
21
|
*
|
|
26
22
|
* Polling (E2): every call carries `waitMs` so a preview_status-aware MCP long-polls
|
|
27
23
|
* (blocks up to waitMs, waking immediately on arrival) instead of us sleeping blind
|
|
@@ -161,7 +157,7 @@ export function renderUsage(verb = "preview") {
|
|
|
161
157
|
merge, surfaces a resolve card on a genuine overlap — the
|
|
162
158
|
default), "ours" (keep yours), "theirs" (keep the store's)
|
|
163
159
|
tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
|
|
164
|
-
tot ${verb} --ref <name>
|
|
160
|
+
tot ${verb} --ref <name> assert the generated candidate/<changeId> ref (custom refs are refused)
|
|
165
161
|
tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
|
|
166
162
|
tot ${verb} --summary "<text>" longer description to accompany the title
|
|
167
163
|
tot ${verb} --summary-file <path> structured summary from a file — JSON
|
|
@@ -446,15 +442,9 @@ export function baseCommitsBehind(git, branch) {
|
|
|
446
442
|
}
|
|
447
443
|
|
|
448
444
|
/**
|
|
449
|
-
* Resolve the git ref to
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
* patch — so the patch MUST be the FULL delta of your branch vs its fork point off
|
|
453
|
-
* `preview`, never "what changed since my last candidate push". Diffing against your
|
|
454
|
-
* own candidate tracking ref is what silently produced an empty patch (and the wrong
|
|
455
|
-
* "no file changes" skip, so no PR opened) after a push that landed but failed to open
|
|
456
|
-
* its PR: that ref already equals HEAD, so the since-last-push delta is empty even
|
|
457
|
-
* though the change vs preview is 19 files. Prefer the merge-base (fork point) with
|
|
445
|
+
* Resolve the git ref used to summarize the candidate's complete delta. Diffing
|
|
446
|
+
* against the already-pushed candidate tracking ref would be empty on retry even
|
|
447
|
+
* though the change vs preview is non-empty. Prefer the merge-base (fork point) with
|
|
458
448
|
* `origin/<baseBranch>`; fall back to the candidate tracking ref, then HEAD~1, then ""
|
|
459
449
|
* (single-commit `git show`) when no base ref resolves. Pure git I/O via the injected
|
|
460
450
|
* runner — unit-tested.
|
|
@@ -766,38 +756,6 @@ export function parseNameStatus(text) {
|
|
|
766
756
|
.filter((e) => e.path);
|
|
767
757
|
}
|
|
768
758
|
|
|
769
|
-
/**
|
|
770
|
-
* Build the g1b `candidate_open` FileChange[] patch from parsed name-status
|
|
771
|
-
* entries — one entry per changed path, `delete:true` for removals (and a
|
|
772
|
-
* rename's old path). Content is read from the committed HEAD blob via
|
|
773
|
-
* `readBlob` (injected — never the working tree, so the patch matches exactly
|
|
774
|
-
* what was pushed). Binary content (doesn't round-trip as clean utf8, or
|
|
775
|
-
* contains a NUL) is sent base64; everything else goes as plain utf8 text.
|
|
776
|
-
* @param {{status:string, path:string, from?:string}[]} entries
|
|
777
|
-
* @param {(path: string) => Buffer} readBlob
|
|
778
|
-
* @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
|
|
779
|
-
*/
|
|
780
|
-
export function buildFilePatch(entries, readBlob) {
|
|
781
|
-
/** @type {Array<{ path: string, content?: string, contentEncoding?: "base64", delete?: true }>} */
|
|
782
|
-
const patch = [];
|
|
783
|
-
for (const e of entries) {
|
|
784
|
-
if (e.status === "R") patch.push({ path: /** @type {string} */ (e.from), delete: true });
|
|
785
|
-
if (e.status === "D") {
|
|
786
|
-
patch.push({ path: e.path, delete: true });
|
|
787
|
-
continue;
|
|
788
|
-
}
|
|
789
|
-
const buf = readBlob(e.path);
|
|
790
|
-
const asUtf8 = buf.toString("utf8");
|
|
791
|
-
const isCleanUtf8 = !asUtf8.includes("\x00") && Buffer.from(asUtf8, "utf8").equals(buf);
|
|
792
|
-
patch.push(
|
|
793
|
-
isCleanUtf8
|
|
794
|
-
? { path: e.path, content: asUtf8 }
|
|
795
|
-
: { path: e.path, content: buf.toString("base64"), contentEncoding: "base64" },
|
|
796
|
-
);
|
|
797
|
-
}
|
|
798
|
-
return patch;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
759
|
/**
|
|
802
760
|
* Derive the g1b forge repo name (`"<tenant>-<tag>"`, e.g. `"acme.com-main"`)
|
|
803
761
|
* from the checkout's authenticated origin remote — the SAME name
|
|
@@ -1034,17 +992,18 @@ export async function resolveActivePointer(client, { repo, active }) {
|
|
|
1034
992
|
|
|
1035
993
|
/**
|
|
1036
994
|
* The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
|
|
1037
|
-
* SHARED `preview` ref).
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
* isolated candidate ref, so two developers — or the same developer on two
|
|
1041
|
-
* branches — never force-push over each other or each other's preview. Pure —
|
|
1042
|
-
* unit-tested.
|
|
995
|
+
* SHARED `preview` ref). The only valid target is the exact ref derived from the
|
|
996
|
+
* stable candidate identity. `--ref` may assert that value, but cannot override it.
|
|
997
|
+
* Pure — unit-tested.
|
|
1043
998
|
* @param {{ ref?: string|null, changeId: string }} opts
|
|
1044
999
|
* @returns {string}
|
|
1045
1000
|
*/
|
|
1046
1001
|
export function resolvePushRef({ ref, changeId }) {
|
|
1047
|
-
|
|
1002
|
+
const expected = candidateRefFor(changeId);
|
|
1003
|
+
if (ref && ref !== expected) {
|
|
1004
|
+
throw new Error(`--ref must be exactly ${expected}; custom refs cannot be registered as this candidate.`);
|
|
1005
|
+
}
|
|
1006
|
+
return expected;
|
|
1048
1007
|
}
|
|
1049
1008
|
|
|
1050
1009
|
const REQUIRED_ATTRIBUTION_REFUSALS = new Set([
|
|
@@ -1052,40 +1011,32 @@ const REQUIRED_ATTRIBUTION_REFUSALS = new Set([
|
|
|
1052
1011
|
"audit_unavailable",
|
|
1053
1012
|
]);
|
|
1054
1013
|
|
|
1055
|
-
class
|
|
1014
|
+
export class CandidateRegistrationError extends Error {
|
|
1056
1015
|
constructor(message) {
|
|
1057
1016
|
super(message);
|
|
1058
|
-
this.name = "
|
|
1017
|
+
this.name = "CandidateRegistrationError";
|
|
1059
1018
|
}
|
|
1060
1019
|
}
|
|
1061
1020
|
|
|
1062
1021
|
/**
|
|
1063
1022
|
* Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
|
|
1064
1023
|
* unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
|
|
1065
|
-
* milestone, symmetric with the hosted s6 draft-as-PR path).
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
* older MCP, version control not configured, preview-access capability, …) is
|
|
1070
|
-
* reported and swallowed — except an audit/identity refusal, which is required
|
|
1071
|
-
* and propagates so the command exits non-zero with the MCP's recovery guidance.
|
|
1024
|
+
* milestone, symmetric with the hosted s6 draft-as-PR path). The Git-capable CLI
|
|
1025
|
+
* has already pushed its real commits, so this call sends only the exact candidate
|
|
1026
|
+
* ref and asserted HEAD plus PR metadata. Any registration failure is fatal: a
|
|
1027
|
+
* pushed ref without a review PR is not a successful submit.
|
|
1072
1028
|
* @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
|
|
1073
|
-
* @param {{ repo: string|null, changeId: string,
|
|
1074
|
-
*
|
|
1029
|
+
* @param {{ repo: string|null, changeId: string, ref: string, headSha: string,
|
|
1030
|
+
* changeSummary: {title:string, body:string[]}, hasChanges: boolean,
|
|
1075
1031
|
* quiet?: boolean }} opts `quiet` (--json) suppresses the human print; the same
|
|
1076
1032
|
* result is still returned for the caller's JSON payload.
|
|
1077
1033
|
*/
|
|
1078
|
-
export async function submitCandidate(client, { repo, changeId,
|
|
1034
|
+
export async function submitCandidate(client, { repo, changeId, ref, headSha, changeSummary, hasChanges, quiet = false }) {
|
|
1079
1035
|
if (!repo) {
|
|
1080
|
-
|
|
1081
|
-
return null;
|
|
1036
|
+
throw new CandidateRegistrationError("Candidate not created: couldn't derive the forge repo from origin.");
|
|
1082
1037
|
}
|
|
1038
|
+
if (!hasChanges) throw new CandidateRegistrationError("Candidate not created: no content changes were found.");
|
|
1083
1039
|
try {
|
|
1084
|
-
const patch = buildFilePatch(patchEntries, readBlob);
|
|
1085
|
-
if (patch.length === 0) {
|
|
1086
|
-
if (!quiet) console.log(` ~ no file changes to open a PR-backed candidate for.`);
|
|
1087
|
-
return null;
|
|
1088
|
-
}
|
|
1089
1040
|
const result = await client.callTool("candidate_open", {
|
|
1090
1041
|
repo,
|
|
1091
1042
|
changeId,
|
|
@@ -1094,22 +1045,33 @@ export async function submitCandidate(client, { repo, changeId, changeSummary, p
|
|
|
1094
1045
|
// truncated PR title/body instead of an entirely-avoidable tool refusal.
|
|
1095
1046
|
title: changeSummary.title.slice(0, 200),
|
|
1096
1047
|
body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
|
|
1097
|
-
|
|
1048
|
+
ref,
|
|
1049
|
+
headSha,
|
|
1050
|
+
idempotencyKey: `candidate-open:${repo}:${changeId}:${headSha}`,
|
|
1098
1051
|
});
|
|
1099
|
-
if (REQUIRED_ATTRIBUTION_REFUSALS.has(result?.status)) {
|
|
1100
|
-
throw new
|
|
1101
|
-
result?.message || "Candidate not created:
|
|
1052
|
+
if (REQUIRED_ATTRIBUTION_REFUSALS.has(result?.status) || result?.status === "error") {
|
|
1053
|
+
throw new CandidateRegistrationError(
|
|
1054
|
+
result?.message || "Candidate not created: the MCP refused registration.",
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
if (typeof result?.prNumber !== "number") {
|
|
1058
|
+
throw new CandidateRegistrationError(
|
|
1059
|
+
result?.message || "Candidate not created: candidate_open returned no pull request.",
|
|
1102
1060
|
);
|
|
1103
1061
|
}
|
|
1062
|
+
if (result.branch !== ref || result.headSha !== headSha) {
|
|
1063
|
+
throw new CandidateRegistrationError(
|
|
1064
|
+
`Candidate not created: MCP registered ${result.branch || "an unknown ref"}@${result.headSha || "unknown"}, expected ${ref}@${headSha}.`,
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
if (isTerminalCandidateState(result.state)) {
|
|
1068
|
+
throw new CandidateRegistrationError(`Candidate not created: candidate_open returned terminal state ${result.state}.`);
|
|
1069
|
+
}
|
|
1104
1070
|
reportCandidate(result, changeId, { quiet });
|
|
1105
1071
|
return result;
|
|
1106
1072
|
} catch (e) {
|
|
1107
|
-
if (e instanceof
|
|
1108
|
-
|
|
1109
|
-
console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
|
|
1110
|
-
console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
|
|
1111
|
-
}
|
|
1112
|
-
return null;
|
|
1073
|
+
if (e instanceof CandidateRegistrationError) throw e;
|
|
1074
|
+
throw new CandidateRegistrationError(`Candidate not created: ${describeReadbackError(e)}`);
|
|
1113
1075
|
}
|
|
1114
1076
|
}
|
|
1115
1077
|
|
|
@@ -1247,10 +1209,18 @@ function emitJson(args, payload) {
|
|
|
1247
1209
|
* `--json` (args.json) suppresses the human-readable stdout narration in favor of
|
|
1248
1210
|
* one structured result object at the end (see buildJsonResult) — stderr
|
|
1249
1211
|
* diagnostics (fail(), `~ …` progress lines) still print either way.
|
|
1250
|
-
* @param {string[]} argv @param {any} ctx
|
|
1212
|
+
* @param {string[]} argv @param {any} ctx
|
|
1213
|
+
* @param {{ verb?: string, env?: NodeJS.ProcessEnv,
|
|
1214
|
+
* createClient?: typeof createMcpClient, establish?: typeof establishSession,
|
|
1215
|
+
* checkout?: typeof checkoutTenant }} [opts]
|
|
1251
1216
|
*/
|
|
1252
|
-
export async function run(argv, ctx, {
|
|
1253
|
-
|
|
1217
|
+
export async function run(argv, ctx, {
|
|
1218
|
+
verb = "preview",
|
|
1219
|
+
env = process.env,
|
|
1220
|
+
createClient = createMcpClient,
|
|
1221
|
+
establish = establishSession,
|
|
1222
|
+
checkout = checkoutTenant,
|
|
1223
|
+
} = {}) {
|
|
1254
1224
|
const args = parseArgs(argv);
|
|
1255
1225
|
if (args.help) {
|
|
1256
1226
|
console.log(renderUsage(verb));
|
|
@@ -1441,9 +1411,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1441
1411
|
// push, because `git push` fast-forwards the local origin/<ref> tracking ref and
|
|
1442
1412
|
// would zero out the "vs what's live in preview" diff. Explicit -m/--summary win;
|
|
1443
1413
|
// otherwise it's generated from git so the change record is never blank. This
|
|
1444
|
-
// same diff
|
|
1445
|
-
// doubles as the source of the PR-backed candidate's file patch (step 2b, below)
|
|
1446
|
-
// — one git read, two consumers, so the PR always matches what's printed here.
|
|
1414
|
+
// same diff supplies the PR summary and the pre-push no-change gate.
|
|
1447
1415
|
// Parameterized on `ref` (b03 — isolated candidate refs): the diff base is YOUR
|
|
1448
1416
|
// candidate ref's own tracking ref, not a shared one, so the summary always reads
|
|
1449
1417
|
// "vs your own last push" once the push ref is known (computed just below).
|
|
@@ -1454,85 +1422,45 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1454
1422
|
return "";
|
|
1455
1423
|
}
|
|
1456
1424
|
};
|
|
1457
|
-
function
|
|
1425
|
+
function buildSubmitSummary(ref) {
|
|
1458
1426
|
const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
|
|
1459
|
-
// Diff against the fork point off `preview`
|
|
1460
|
-
//
|
|
1461
|
-
// and re-applies this patch). NOT the candidate's own tracking ref: after a push
|
|
1462
|
-
// that landed but failed to open its PR, that ref equals HEAD → empty patch →
|
|
1463
|
-
// wrong "no file changes" → no PR. See resolvePatchBase.
|
|
1427
|
+
// Diff against the fork point off `preview` so retries keep the complete summary
|
|
1428
|
+
// even when the previously pushed candidate tracking ref already equals HEAD.
|
|
1464
1429
|
const base = resolvePatchBase(gitSafe, ref);
|
|
1465
1430
|
const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
|
|
1466
|
-
const
|
|
1467
|
-
const files =
|
|
1431
|
+
const changedEntries = parseNameStatus(gitSafe(statusCmd));
|
|
1432
|
+
const files = changedEntries.map((e) => e.path);
|
|
1468
1433
|
const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
|
|
1469
1434
|
const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
|
|
1470
|
-
return { changeSummary,
|
|
1435
|
+
return { changeSummary, changedEntries };
|
|
1471
1436
|
}
|
|
1472
1437
|
|
|
1473
1438
|
// The MCP session is needed BOTH to mint a fresh forge push credential (decision
|
|
1474
1439
|
// B — right below) and for the candidate/preview read-back after, so establish it
|
|
1475
1440
|
// ONCE, up front, and reuse it for the whole flow.
|
|
1476
1441
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
1477
|
-
const client =
|
|
1442
|
+
const client = createClient(baseUrl);
|
|
1478
1443
|
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
1479
1444
|
|
|
1480
1445
|
// Branch-bound (u4) + the isolated candidate ref (b03): resolved BEFORE the push,
|
|
1481
1446
|
// since the push target itself depends on it — so the raw git push and the
|
|
1482
1447
|
// PR-backed candidate (step 2b, below) always land on the SAME branch. `active`/
|
|
1483
1448
|
// `statePath` are local filesystem reads (candidate-state.mjs) — no session
|
|
1484
|
-
// needed — so they're available
|
|
1449
|
+
// needed — so they're available before any remote write.
|
|
1485
1450
|
const branch = currentBranch(gitSafe);
|
|
1486
1451
|
const statePath = defaultCandidateStatePath(env);
|
|
1487
1452
|
let active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
|
|
1488
1453
|
|
|
1489
1454
|
let session;
|
|
1490
1455
|
try {
|
|
1491
|
-
session = await
|
|
1456
|
+
session = await establish(client, { env, prefer: args.identity || undefined });
|
|
1492
1457
|
} catch (e) {
|
|
1493
|
-
// Not signed in / MCP unreachable — we can't mint a fresh credential, so fall
|
|
1494
|
-
// back to pushing over the checkout's EXISTING embedded remote (pre-B behavior:
|
|
1495
|
-
// no worse than before) and skip the read-back that needs a session. The push
|
|
1496
|
-
// still lands if that embedded token is live. actorKeyFor(null) degrades to the
|
|
1497
|
-
// generic "developer" key — still isolated PER BRANCH (never the shared ref),
|
|
1498
|
-
// just not per-developer until sign-in succeeds.
|
|
1499
|
-
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, forkCandidate: args.forkCandidate });
|
|
1500
|
-
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1501
|
-
const { changeSummary } = buildSummaryAndPatch(ref);
|
|
1502
|
-
console.error(`~ pushing ${short} → ${ref} (origin)`);
|
|
1503
|
-
try {
|
|
1504
|
-
const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
|
|
1505
|
-
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
1506
|
-
emitGitOp("push", true, { command: verb });
|
|
1507
|
-
} catch (pushErr) {
|
|
1508
|
-
emitGitOp("push", false, {
|
|
1509
|
-
command: verb,
|
|
1510
|
-
errorClass: isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr) ? "forge_auth" : "git_push_failed",
|
|
1511
|
-
});
|
|
1512
|
-
const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
|
|
1513
|
-
// This is the NO-SESSION path pushing the clone-time embedded credential —
|
|
1514
|
-
// which rotation kills the moment any fresh mint happens elsewhere. An auth
|
|
1515
|
-
// failure here is therefore almost always "you're not signed in IN THIS
|
|
1516
|
-
// SHELL", not a network problem; the old remote-is-reachable hint sent a
|
|
1517
|
-
// human down the wrong path live (Trello-13075 polish).
|
|
1518
|
-
const hint = isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr)
|
|
1519
|
-
? "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"
|
|
1520
|
-
: "check your commit and that the checkout's remote is reachable, then re-run";
|
|
1521
|
-
console.error(fail(msg, hint));
|
|
1522
|
-
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
|
|
1523
|
-
return 1;
|
|
1524
|
-
}
|
|
1525
|
-
if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
1526
|
-
printChangeSummary(changeSummary, { quiet: args.json });
|
|
1527
1458
|
const note = e instanceof AuthUnavailableError
|
|
1528
|
-
? `
|
|
1529
|
-
: `couldn't reach Token of Trust
|
|
1530
|
-
if (!args.json)
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
}
|
|
1534
|
-
emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
|
|
1535
|
-
return 0;
|
|
1459
|
+
? `Candidate not created: ${e.hint || "developer sign-in is required"}.`
|
|
1460
|
+
: `Candidate not created: couldn't reach Token of Trust (${describeReadbackError(e)}).`;
|
|
1461
|
+
if (!args.json) console.error(fail(note, "run `tot login`, then re-run; no candidate ref was pushed"));
|
|
1462
|
+
emitJson(args, buildJsonResult({ ok: false, commit, error: note }));
|
|
1463
|
+
return 1;
|
|
1536
1464
|
}
|
|
1537
1465
|
|
|
1538
1466
|
// u17 — before REUSING a remembered active pointer, confirm its PR is still open.
|
|
@@ -1564,9 +1492,41 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1564
1492
|
// (right below) and the PR-backed candidate (step 2b): the two never point at
|
|
1565
1493
|
// different branches. See chooseChangeId's doc for the --fork-candidate /
|
|
1566
1494
|
// active-pointer rules.
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1495
|
+
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
|
|
1496
|
+
let ref;
|
|
1497
|
+
try {
|
|
1498
|
+
ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1499
|
+
} catch (e) {
|
|
1500
|
+
const note = String(e?.message || e);
|
|
1501
|
+
if (!args.json) console.error(fail(note, "omit --ref and re-run"));
|
|
1502
|
+
emitJson(args, buildJsonResult({ ok: false, commit, changeId, error: note }));
|
|
1503
|
+
return 1;
|
|
1504
|
+
}
|
|
1505
|
+
const { changeSummary, changedEntries } = buildSubmitSummary(ref);
|
|
1506
|
+
if (changedEntries.length === 0) {
|
|
1507
|
+
const note = "Candidate not created: no content changes were found.";
|
|
1508
|
+
if (!args.json) console.error(fail(note, "commit a content/public/theme.json change, then re-run"));
|
|
1509
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note, noChanges: true }));
|
|
1510
|
+
return 1;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Persist the chosen identity before any remote write. A failed push or MCP
|
|
1514
|
+
// registration therefore retries the same candidate/ref instead of minting an
|
|
1515
|
+
// orphan. Failing to persist a forked identity is itself unsafe, so refuse.
|
|
1516
|
+
if (!repo) {
|
|
1517
|
+
const note = "Candidate not created: couldn't derive the forge repo from origin.";
|
|
1518
|
+
if (!args.json) console.error(fail(note, "run this command inside a `tot clone` checkout"));
|
|
1519
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
|
|
1520
|
+
return 1;
|
|
1521
|
+
}
|
|
1522
|
+
try {
|
|
1523
|
+
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
1524
|
+
} catch {
|
|
1525
|
+
const note = "Candidate not created: couldn't persist retry identity.";
|
|
1526
|
+
if (!args.json) console.error(fail(note, "fix ~/.tot permissions, then re-run; no candidate ref was pushed"));
|
|
1527
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
|
|
1528
|
+
return 1;
|
|
1529
|
+
}
|
|
1570
1530
|
|
|
1571
1531
|
// 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
|
|
1572
1532
|
// (decision B). The token baked into `.git/config` at clone time expires within
|
|
@@ -1577,7 +1537,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1577
1537
|
const tag = tagFromRepoName(repo, tenant);
|
|
1578
1538
|
const mintRemote = async () => {
|
|
1579
1539
|
try {
|
|
1580
|
-
const res = await
|
|
1540
|
+
const res = await checkout(client, { tenant, tag, cloneDir: null, redact: redactUrl });
|
|
1581
1541
|
return res.gitRemote || null;
|
|
1582
1542
|
} catch (e) {
|
|
1583
1543
|
console.error(
|
|
@@ -1611,28 +1571,19 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1611
1571
|
try {
|
|
1612
1572
|
// Bind the active tenant so preview_status/candidate_open read the right scope
|
|
1613
1573
|
// (idempotent — checkoutTenant already switched when the fresh mint succeeded).
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
// propagate and make this command fail: an unaudited PR is never an acceptable
|
|
1619
|
-
// successful submit.
|
|
1620
|
-
// `changeId`/`stableId`/`persist` were already decided above (they picked the
|
|
1621
|
-
// push ref too); if the chosen candidate turns out to be merged/closed, roll to
|
|
1622
|
-
// a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
|
|
1623
|
-
// above.)
|
|
1624
|
-
const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1625
|
-
|
|
1626
|
-
let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
1627
|
-
|
|
1628
|
-
if (candidate && isTerminalCandidateState(candidate.state)) {
|
|
1629
|
-
const rolled = mintFreshChangeId(stableId);
|
|
1630
|
-
if (!args.json) console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
|
|
1631
|
-
changeId = rolled;
|
|
1632
|
-
persist = true;
|
|
1633
|
-
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
1574
|
+
try {
|
|
1575
|
+
await client.callTool("client_switch", { tenant });
|
|
1576
|
+
} catch (e) {
|
|
1577
|
+
throw new CandidateRegistrationError(`Candidate not created: tenant scope could not be bound (${describeReadbackError(e)}).`);
|
|
1634
1578
|
}
|
|
1635
1579
|
|
|
1580
|
+
// 2b. Register the exact ref and asserted commit that git just pushed. No file
|
|
1581
|
+
// bytes cross MCP and the server does not reconstruct or replace these commits.
|
|
1582
|
+
const candidate = await submitCandidate(client, {
|
|
1583
|
+
repo, changeId, ref, headSha: commit, changeSummary,
|
|
1584
|
+
hasChanges: changedEntries.length > 0, quiet: args.json,
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1636
1587
|
// c3 — BORN-REBASED at submit (shift-left prevention #2). The candidate is open;
|
|
1637
1588
|
// if the base has drifted (the SAME signal c2 warned on, pre-push) rebuild it from
|
|
1638
1589
|
// the CURRENT base tip via candidate_refresh BEFORE finalizing, so it enters the
|
|
@@ -1663,13 +1614,6 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1663
1614
|
if (freshPrUrl && !args.json) {
|
|
1664
1615
|
console.log(`\n ▸ Your fresh preview will appear at:\n ${freshPrUrl}\n (building on the current store — this link goes live once reconcile completes)`);
|
|
1665
1616
|
}
|
|
1666
|
-
// The rebuild keeps the STABLE changeId, so the persisted active pointer stays
|
|
1667
|
-
// valid — record it (best-effort) exactly as the normal open path does below.
|
|
1668
|
-
if (persist && repo) {
|
|
1669
|
-
try {
|
|
1670
|
-
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
1671
|
-
} catch { /* best-effort local hint */ }
|
|
1672
|
-
}
|
|
1673
1617
|
emitJson(args, buildJsonResult({
|
|
1674
1618
|
ok: true, ref, commit, changeId,
|
|
1675
1619
|
candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
|
|
@@ -1703,14 +1647,6 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1703
1647
|
console.log(`\n ▸ Your preview will appear at:\n ${previewPrUrl}\n (building — this link goes live once reconcile completes)`);
|
|
1704
1648
|
}
|
|
1705
1649
|
|
|
1706
|
-
// Remember the active candidate only on a real, non-terminal open (best-effort;
|
|
1707
|
-
// never let a state-write failure break the submit).
|
|
1708
|
-
if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
|
|
1709
|
-
try {
|
|
1710
|
-
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
1711
|
-
} catch { /* best-effort local hint — a miss just re-derives the stable id */ }
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
1650
|
// Poll the sha `preview_status` actually resolves against — the candidate head, not
|
|
1715
1651
|
// the local commit, whenever the candidate names its own. Printed first (when they
|
|
1716
1652
|
// differ) so the developer reads the result against the right sha.
|
|
@@ -1763,27 +1699,25 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1763
1699
|
// — automation doesn't want a browser popping up.
|
|
1764
1700
|
// `commit: statusSha` — the diagnostic blocks name the sha that was actually polled,
|
|
1765
1701
|
// so a "never dispatched" hint points at a sha `preview_status` knows about.
|
|
1766
|
-
const noChanges =
|
|
1702
|
+
const noChanges = changedEntries.length === 0;
|
|
1767
1703
|
reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit: statusSha, ref, verb, noChanges });
|
|
1768
|
-
|
|
1769
|
-
|
|
1704
|
+
const ok = previewSubmitSucceeded(status);
|
|
1705
|
+
emitJson(args, buildJsonResult({ ok, ref, commit, changeId, candidate, status, previewPrUrl, noChanges }));
|
|
1706
|
+
return ok ? 0 : 1;
|
|
1770
1707
|
} catch (e) {
|
|
1771
1708
|
progress?.stop();
|
|
1772
|
-
if (e instanceof
|
|
1709
|
+
if (e instanceof CandidateRegistrationError) {
|
|
1773
1710
|
const note = `${e.message} Your preview push is in, but no review PR was created.`;
|
|
1774
|
-
if (!args.json) console.error(fail(note,
|
|
1711
|
+
if (!args.json) console.error(fail(note, `re-run \`tot submit\`; retry will reuse ${ref}`));
|
|
1775
1712
|
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
|
|
1776
1713
|
return 1;
|
|
1777
1714
|
}
|
|
1778
1715
|
const note = e instanceof AuthUnavailableError
|
|
1779
|
-
? `
|
|
1780
|
-
: `
|
|
1781
|
-
if (!args.json) {
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
}
|
|
1785
|
-
emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
|
|
1786
|
-
return 0;
|
|
1716
|
+
? `Preview result unavailable: ${e.hint || "developer sign-in is required"}.`
|
|
1717
|
+
: `Preview result unavailable: ${describeReadbackError(e)}.`;
|
|
1718
|
+
if (!args.json) console.error(fail(note, `the candidate ref remains at ${ref}; fix the error and re-run`));
|
|
1719
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
|
|
1720
|
+
return 1;
|
|
1787
1721
|
}
|
|
1788
1722
|
}
|
|
1789
1723
|
|
|
@@ -1819,6 +1753,12 @@ export function normalizePreviewStatus(r) {
|
|
|
1819
1753
|
};
|
|
1820
1754
|
}
|
|
1821
1755
|
|
|
1756
|
+
/** A submit succeeds only when reconcile has neither failed nor failed to start. */
|
|
1757
|
+
export function previewSubmitSucceeded(status) {
|
|
1758
|
+
const knownSuccessState = status?.status === "pending" || status?.status === "reconciled";
|
|
1759
|
+
return knownSuccessState && !status?.notDispatched && !status?.forwardFailed;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1822
1762
|
/**
|
|
1823
1763
|
* Poll `preview_status` while reconcile is pending (and, with untilShipped, while
|
|
1824
1764
|
* reconciled-but-not-yet-shipped). E2: every call carries `waitMs` so a
|
|
@@ -2026,7 +1966,7 @@ export function formatNotDispatchedBlock({ commit = null, ref = null, noChanges
|
|
|
2026
1966
|
// down two wrong debugging paths).
|
|
2027
1967
|
const causes = [
|
|
2028
1968
|
...(noChanges
|
|
2029
|
-
? [` • your submit contained NO content changes — a candidate with no diff opens no PR and builds nothing (make an edit,
|
|
1969
|
+
? [` • your submit contained NO content changes — a candidate with no diff opens no PR and builds nothing (make and commit an edit, then re-run \`tot ${verb}\`),`]
|
|
2030
1970
|
: []),
|
|
2031
1971
|
` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
|
|
2032
1972
|
` • your session is scoped to a different store than the one you pushed.`,
|