@tokenoftrust/cli 2.0.10 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.0.10",
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",
@@ -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 the common case needs NO state and writes
7
- * nothing here (backward-compatible with the stateless original). This file only
8
- * records a DIVERGENCE from that stable default:
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
- * OLD branch-less key so existing devs' state is byte-identical (zero migration),
22
- * and a branch-scoped read that misses FALLS BACK to that old key so state written
23
- * before the rekey (or by the default branch) is never orphaned. Same atomic-write
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
- mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
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
- * On a non-default branch whose branch-scoped key misses, FALLS BACK to the legacy
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
- const primary = recordChangeId(map[stateKey(mcpUrl, repo, branch)]);
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
- const map = readMap(filePath);
109
- map[stateKey(mcpUrl, repo, branch)] = { changeId, updatedAt: Date.now() };
110
- writeMap(filePath, map);
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
- const map = readMap(filePath);
116
- const key = stateKey(mcpUrl, repo, branch);
117
- if (key in map) {
140
+ updateMap(filePath, (map) => {
141
+ const key = stateKey(mcpUrl, repo, branch);
142
+ if (!(key in map)) return false;
118
143
  delete map[key];
119
- writeMap(filePath, map);
120
- }
144
+ return true;
145
+ });
121
146
  }
122
147
 
123
148
  /**
@@ -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; an explicit `--ref` can still
8
- * target a literal ref name for back-compat) which triggers reconcile,
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 remains best-effort for compatibility failures (older MCP,
18
- * version-control not configured, preview-access capability), but an attribution
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. If that tool isn't present (older MCP) the command still validates +
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> push ref (default: your own isolated candidate ref see \`tot pr\`)
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 diff HEAD against for the candidate's file patch (and its
450
- * summary). The candidate PR is `preview + your file changes`, and `candidate_open`
451
- * SERVER-CUTS the candidate branch back to the current preview tip and re-applies the
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). An explicit `--ref` always wins the escape hatch /
1038
- * back-compat path (e.g. `--ref preview` reproduces the old shared-ref push for
1039
- * any tooling that still reads it by that literal name); otherwise it's YOUR OWN
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
- return ref || candidateRefFor(changeId);
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 CandidateAttributionError extends Error {
1014
+ export class CandidateRegistrationError extends Error {
1056
1015
  constructor(message) {
1057
1016
  super(message);
1058
- this.name = "CandidateAttributionError";
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). Builds the patch
1066
- * from the SAME name-status diff `buildChangeSummary` reports on, reuses its
1067
- * title/body as the PR title/description, and prints the resulting
1068
- * changeId/PR number/URL. Best-effort: any failure (no repo could be derived,
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, changeSummary: {title:string, body:string[]},
1074
- * patchEntries: {status:string, path:string, from?:string}[], readBlob: (path:string)=>Buffer,
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, changeSummary, patchEntries, readBlob, quiet = false }) {
1034
+ export async function submitCandidate(client, { repo, changeId, ref, headSha, changeSummary, hasChanges, quiet = false }) {
1079
1035
  if (!repo) {
1080
- if (!quiet) console.log(` ~ couldn't derive the forge repo from the checkout's remote — skipping the PR-backed candidate.`);
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
- patch,
1048
+ ref,
1049
+ headSha,
1050
+ idempotencyKey: `candidate-open:${repo}:${changeId}:${headSha}`,
1098
1051
  });
1099
- if (REQUIRED_ATTRIBUTION_REFUSALS.has(result?.status)) {
1100
- throw new CandidateAttributionError(
1101
- result?.message || "Candidate not created: verified actor attribution is required.",
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 CandidateAttributionError) throw e;
1108
- if (!quiet) {
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
 
@@ -1158,9 +1120,9 @@ function reportCandidate(result, changeId, { quiet = false } = {}) {
1158
1120
  * status?: {status?: string, reconcile?: object|null, compliance?: object|null,
1159
1121
  * previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
1160
1122
  * notDispatched?: boolean, delivery?: object|null}|null,
1161
- * previewPrUrl?: string|null, error?: string|null, note?: string|null }} input
1123
+ * previewPrUrl?: string|null, error?: string|null, note?: string|null, noChanges?: boolean }} input
1162
1124
  */
1163
- export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null }) {
1125
+ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null, noChanges = false }) {
1164
1126
  return {
1165
1127
  ok,
1166
1128
  ref,
@@ -1183,6 +1145,13 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
1183
1145
  forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
1184
1146
  delivery: status?.delivery ?? null,
1185
1147
  previewPrUrl,
1148
+ // Honest-diagnosis parity with the human-readable formatNotDispatchedBlock: a
1149
+ // `--json` caller gets the SAME "why" signal a human sees on the console. Without
1150
+ // this, `candidate:null, notDispatched:true, delivery:null` reads identically for
1151
+ // "no file diff → candidate_open never even ran" and "webhook never registered" /
1152
+ // "wrong tenant scope" — an automation caller (or a human piping --json) had no way
1153
+ // to tell an empty-diff no-op from a genuine platform dispatch failure.
1154
+ ...(noChanges ? { noChanges: true } : {}),
1186
1155
  ...(error ? { error } : {}),
1187
1156
  ...(note ? { note } : {}),
1188
1157
  };
@@ -1240,10 +1209,18 @@ function emitJson(args, payload) {
1240
1209
  * `--json` (args.json) suppresses the human-readable stdout narration in favor of
1241
1210
  * one structured result object at the end (see buildJsonResult) — stderr
1242
1211
  * diagnostics (fail(), `~ …` progress lines) still print either way.
1243
- * @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
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]
1244
1216
  */
1245
- export async function run(argv, ctx, { verb = "preview" } = {}) {
1246
- const env = process.env;
1217
+ export async function run(argv, ctx, {
1218
+ verb = "preview",
1219
+ env = process.env,
1220
+ createClient = createMcpClient,
1221
+ establish = establishSession,
1222
+ checkout = checkoutTenant,
1223
+ } = {}) {
1247
1224
  const args = parseArgs(argv);
1248
1225
  if (args.help) {
1249
1226
  console.log(renderUsage(verb));
@@ -1434,9 +1411,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1434
1411
  // push, because `git push` fast-forwards the local origin/<ref> tracking ref and
1435
1412
  // would zero out the "vs what's live in preview" diff. Explicit -m/--summary win;
1436
1413
  // otherwise it's generated from git so the change record is never blank. This
1437
- // same diff (name-status, so candidate_open also knows adds/deletes/renames)
1438
- // doubles as the source of the PR-backed candidate's file patch (step 2b, below)
1439
- // — 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.
1440
1415
  // Parameterized on `ref` (b03 — isolated candidate refs): the diff base is YOUR
1441
1416
  // candidate ref's own tracking ref, not a shared one, so the summary always reads
1442
1417
  // "vs your own last push" once the push ref is known (computed just below).
@@ -1447,85 +1422,45 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1447
1422
  return "";
1448
1423
  }
1449
1424
  };
1450
- function buildSummaryAndPatch(ref) {
1425
+ function buildSubmitSummary(ref) {
1451
1426
  const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
1452
- // Diff against the fork point off `preview` the FULL branch delta — so the patch
1453
- // is complete for candidate_open's server-cut (which resets the branch to preview
1454
- // and re-applies this patch). NOT the candidate's own tracking ref: after a push
1455
- // that landed but failed to open its PR, that ref equals HEAD → empty patch →
1456
- // 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.
1457
1429
  const base = resolvePatchBase(gitSafe, ref);
1458
1430
  const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
1459
- const patchEntries = parseNameStatus(gitSafe(statusCmd));
1460
- const files = patchEntries.map((e) => e.path);
1431
+ const changedEntries = parseNameStatus(gitSafe(statusCmd));
1432
+ const files = changedEntries.map((e) => e.path);
1461
1433
  const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
1462
1434
  const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
1463
- return { changeSummary, patchEntries };
1435
+ return { changeSummary, changedEntries };
1464
1436
  }
1465
1437
 
1466
1438
  // The MCP session is needed BOTH to mint a fresh forge push credential (decision
1467
1439
  // B — right below) and for the candidate/preview read-back after, so establish it
1468
1440
  // ONCE, up front, and reuse it for the whole flow.
1469
1441
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
1470
- const client = createMcpClient(baseUrl);
1442
+ const client = createClient(baseUrl);
1471
1443
  const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
1472
1444
 
1473
1445
  // Branch-bound (u4) + the isolated candidate ref (b03): resolved BEFORE the push,
1474
1446
  // since the push target itself depends on it — so the raw git push and the
1475
1447
  // PR-backed candidate (step 2b, below) always land on the SAME branch. `active`/
1476
1448
  // `statePath` are local filesystem reads (candidate-state.mjs) — no session
1477
- // needed — so they're available even on the no-session fallback path below.
1449
+ // needed — so they're available before any remote write.
1478
1450
  const branch = currentBranch(gitSafe);
1479
1451
  const statePath = defaultCandidateStatePath(env);
1480
1452
  let active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
1481
1453
 
1482
1454
  let session;
1483
1455
  try {
1484
- session = await establishSession(client, { env, prefer: args.identity || undefined });
1456
+ session = await establish(client, { env, prefer: args.identity || undefined });
1485
1457
  } catch (e) {
1486
- // Not signed in / MCP unreachable — we can't mint a fresh credential, so fall
1487
- // back to pushing over the checkout's EXISTING embedded remote (pre-B behavior:
1488
- // no worse than before) and skip the read-back that needs a session. The push
1489
- // still lands if that embedded token is live. actorKeyFor(null) degrades to the
1490
- // generic "developer" key — still isolated PER BRANCH (never the shared ref),
1491
- // just not per-developer until sign-in succeeds.
1492
- const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, forkCandidate: args.forkCandidate });
1493
- const ref = resolvePushRef({ ref: args.ref, changeId });
1494
- const { changeSummary } = buildSummaryAndPatch(ref);
1495
- console.error(`~ pushing ${short} → ${ref} (origin)`);
1496
- try {
1497
- const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
1498
- if (out.trim()) console.error(redactUrl(out.trim()));
1499
- emitGitOp("push", true, { command: verb });
1500
- } catch (pushErr) {
1501
- emitGitOp("push", false, {
1502
- command: verb,
1503
- errorClass: isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr) ? "forge_auth" : "git_push_failed",
1504
- });
1505
- const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
1506
- // This is the NO-SESSION path pushing the clone-time embedded credential —
1507
- // which rotation kills the moment any fresh mint happens elsewhere. An auth
1508
- // failure here is therefore almost always "you're not signed in IN THIS
1509
- // SHELL", not a network problem; the old remote-is-reachable hint sent a
1510
- // human down the wrong path live (Trello-13075 polish).
1511
- const hint = isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr)
1512
- ? "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"
1513
- : "check your commit and that the checkout's remote is reachable, then re-run";
1514
- console.error(fail(msg, hint));
1515
- emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
1516
- return 1;
1517
- }
1518
- if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
1519
- printChangeSummary(changeSummary, { quiet: args.json });
1520
1458
  const note = e instanceof AuthUnavailableError
1521
- ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1522
- : `couldn't reach Token of Trust for the result read-back: ${describeReadbackError(e)}`;
1523
- if (!args.json) {
1524
- console.log(` (${note})`);
1525
- console.log(` Your push is in; the preview updates once reconcile completes.`);
1526
- }
1527
- emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
1528
- 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;
1529
1464
  }
1530
1465
 
1531
1466
  // u17 — before REUSING a remembered active pointer, confirm its PR is still open.
@@ -1557,9 +1492,41 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1557
1492
  // (right below) and the PR-backed candidate (step 2b): the two never point at
1558
1493
  // different branches. See chooseChangeId's doc for the --fork-candidate /
1559
1494
  // active-pointer rules.
1560
- let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
1561
- const ref = resolvePushRef({ ref: args.ref, changeId });
1562
- const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
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
+ }
1563
1530
 
1564
1531
  // 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
1565
1532
  // (decision B). The token baked into `.git/config` at clone time expires within
@@ -1570,7 +1537,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1570
1537
  const tag = tagFromRepoName(repo, tenant);
1571
1538
  const mintRemote = async () => {
1572
1539
  try {
1573
- const res = await checkoutTenant(client, { tenant, tag, cloneDir: null, redact: redactUrl });
1540
+ const res = await checkout(client, { tenant, tag, cloneDir: null, redact: redactUrl });
1574
1541
  return res.gitRemote || null;
1575
1542
  } catch (e) {
1576
1543
  console.error(
@@ -1604,28 +1571,19 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1604
1571
  try {
1605
1572
  // Bind the active tenant so preview_status/candidate_open read the right scope
1606
1573
  // (idempotent — checkoutTenant already switched when the fresh mint succeeded).
1607
- await client.callTool("client_switch", { tenant });
1608
-
1609
- // 2b. PR-backed candidate (g1b candidate_open, unit c1). Compatibility and
1610
- // capability failures remain best-effort, but the MCP's audit/identity refusals
1611
- // propagate and make this command fail: an unaudited PR is never an acceptable
1612
- // successful submit.
1613
- // `changeId`/`stableId`/`persist` were already decided above (they picked the
1614
- // push ref too); if the chosen candidate turns out to be merged/closed, roll to
1615
- // a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
1616
- // above.)
1617
- const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
1618
-
1619
- let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
1620
-
1621
- if (candidate && isTerminalCandidateState(candidate.state)) {
1622
- const rolled = mintFreshChangeId(stableId);
1623
- if (!args.json) console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
1624
- changeId = rolled;
1625
- persist = true;
1626
- 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)}).`);
1627
1578
  }
1628
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
+
1629
1587
  // c3 — BORN-REBASED at submit (shift-left prevention #2). The candidate is open;
1630
1588
  // if the base has drifted (the SAME signal c2 warned on, pre-push) rebuild it from
1631
1589
  // the CURRENT base tip via candidate_refresh BEFORE finalizing, so it enters the
@@ -1656,13 +1614,6 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1656
1614
  if (freshPrUrl && !args.json) {
1657
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)`);
1658
1616
  }
1659
- // The rebuild keeps the STABLE changeId, so the persisted active pointer stays
1660
- // valid — record it (best-effort) exactly as the normal open path does below.
1661
- if (persist && repo) {
1662
- try {
1663
- writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
1664
- } catch { /* best-effort local hint */ }
1665
- }
1666
1617
  emitJson(args, buildJsonResult({
1667
1618
  ok: true, ref, commit, changeId,
1668
1619
  candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
@@ -1696,14 +1647,6 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1696
1647
  console.log(`\n ▸ Your preview will appear at:\n ${previewPrUrl}\n (building — this link goes live once reconcile completes)`);
1697
1648
  }
1698
1649
 
1699
- // Remember the active candidate only on a real, non-terminal open (best-effort;
1700
- // never let a state-write failure break the submit).
1701
- if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
1702
- try {
1703
- writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
1704
- } catch { /* best-effort local hint — a miss just re-derives the stable id */ }
1705
- }
1706
-
1707
1650
  // Poll the sha `preview_status` actually resolves against — the candidate head, not
1708
1651
  // the local commit, whenever the candidate names its own. Printed first (when they
1709
1652
  // differ) so the developer reads the result against the right sha.
@@ -1756,26 +1699,25 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1756
1699
  // — automation doesn't want a browser popping up.
1757
1700
  // `commit: statusSha` — the diagnostic blocks name the sha that was actually polled,
1758
1701
  // so a "never dispatched" hint points at a sha `preview_status` knows about.
1759
- reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit: statusSha, ref, verb, noChanges: patchEntries.length === 0 });
1760
- emitJson(args, buildJsonResult({ ok: status?.status !== "failed", ref, commit, changeId, candidate, status, previewPrUrl }));
1761
- return status?.status === "failed" ? 1 : 0;
1702
+ const noChanges = changedEntries.length === 0;
1703
+ reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit: statusSha, ref, verb, noChanges });
1704
+ const ok = previewSubmitSucceeded(status);
1705
+ emitJson(args, buildJsonResult({ ok, ref, commit, changeId, candidate, status, previewPrUrl, noChanges }));
1706
+ return ok ? 0 : 1;
1762
1707
  } catch (e) {
1763
1708
  progress?.stop();
1764
- if (e instanceof CandidateAttributionError) {
1709
+ if (e instanceof CandidateRegistrationError) {
1765
1710
  const note = `${e.message} Your preview push is in, but no review PR was created.`;
1766
- if (!args.json) console.error(fail(note, "follow the identity guidance above, then re-run `tot submit`"));
1711
+ if (!args.json) console.error(fail(note, `re-run \`tot submit\`; retry will reuse ${ref}`));
1767
1712
  emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
1768
1713
  return 1;
1769
1714
  }
1770
1715
  const note = e instanceof AuthUnavailableError
1771
- ? `sign in to see the reconcile/compliance/preview result ${e.hint || "developer sign-in pending"}`
1772
- : `reconcile is running — the result read-back isn't available yet: ${describeReadbackError(e)}`;
1773
- if (!args.json) {
1774
- console.log(` (${note})`);
1775
- console.log(` Your push is in; the preview updates once reconcile completes.`);
1776
- }
1777
- emitJson(args, buildJsonResult({ ok: true, ref, commit, changeId, note }));
1778
- 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;
1779
1721
  }
1780
1722
  }
1781
1723
 
@@ -1811,6 +1753,12 @@ export function normalizePreviewStatus(r) {
1811
1753
  };
1812
1754
  }
1813
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
+
1814
1762
  /**
1815
1763
  * Poll `preview_status` while reconcile is pending (and, with untilShipped, while
1816
1764
  * reconciled-but-not-yet-shipped). E2: every call carries `waitMs` so a
@@ -2018,7 +1966,7 @@ export function formatNotDispatchedBlock({ commit = null, ref = null, noChanges
2018
1966
  // down two wrong debugging paths).
2019
1967
  const causes = [
2020
1968
  ...(noChanges
2021
- ? [` • 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\`),`]
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}\`),`]
2022
1970
  : []),
2023
1971
  ` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
2024
1972
  ` • your session is scoped to a different store than the one you pushed.`,
package/src/validate.mjs CHANGED
@@ -742,7 +742,12 @@ function walk(dir, pred) {
742
742
 
743
743
  // --- link / asset extraction -------------------------------------------------
744
744
  const HREF_RE = /\bhref\s*=\s*"([^"]*)"/gi;
745
- const SRC_RE = /\b(?:src|srcset)\s*=\s*"([^"]*)"/gi;
745
+ const SRC_RE = /\bsrc\s*=\s*"([^"]*)"/gi;
746
+ // `srcset` carries a comma-separated list of `url descriptor` pairs (e.g.
747
+ // "a.webp 480w, b.webp 800w"), not a single URL — each candidate must be split
748
+ // off its descriptor before being checked as an asset. Mirrors
749
+ // scripts/publish/lib/asset-reachability.mjs's srcset handling.
750
+ const SRCSET_RE = /\bsrcset\s*=\s*"([^"]*)"/gi;
746
751
  const STYLE_OPEN_WITH_ATTRS_RE = /<style\s+[^>]*>/i;
747
752
  /** An in-page skip link: an anchor to a #main-ish target, or one carrying a skip class. */
748
753
  const SKIP_LINK_RE = /<a\b[^>]*(?:class="[^"]*\bskip[-\w]*\b[^"]*"|href="#(?:main|content|main-content)\b")/i;
@@ -974,6 +979,14 @@ export function validateTenant(tenantDir, opts = {}) {
974
979
  const f = checkAsset(m[1].trim(), r, publicDir, opts.tenantId || config?.tenant);
975
980
  if (f) findings.push(f);
976
981
  }
982
+ for (const m of html.matchAll(SRCSET_RE)) {
983
+ for (const candidate of m[1].split(",")) {
984
+ const url = candidate.trim().split(/\s+/)[0];
985
+ if (!url) continue;
986
+ const f = checkAsset(url, r, publicDir, opts.tenantId || config?.tenant);
987
+ if (f) findings.push(f);
988
+ }
989
+ }
977
990
  }
978
991
 
979
992
  // 5. git conflict markers — advisory (never blocks), but LOUD: a half-resolved