@tokenoftrust/cli 1.4.0-rc.16 → 1.4.0-rc.18

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": "1.4.0-rc.16",
3
+ "version": "1.4.0-rc.18",
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",
@@ -756,12 +756,15 @@ export async function submitCandidate(client, { repo, changeId, changeSummary, p
756
756
  }
757
757
 
758
758
  /** Print the candidate_open result: the PR the approver reviews, or the MCP's own
759
- * refusal message when it couldn't open/update one. `quiet` (--json) suppresses it. */
759
+ * refusal message when it couldn't open/update one. `quiet` (--json) suppresses it.
760
+ * Deliberately does NOT print `result.url` — that's the INTERNAL forge (Gitea) PR
761
+ * link, plumbing a developer never needs to see (DZ, 2026-08-15); the product
762
+ * surface is the shareable /preview/<tenant>/pr/<N> URL printed right after
763
+ * (shareablePrUrl). The forge URL still rides the --json payload for tooling. */
760
764
  function reportCandidate(result, changeId, { quiet = false } = {}) {
761
765
  if (quiet) return;
762
766
  if (result && typeof result.prNumber === "number") {
763
767
  console.log(`\n ✓ candidate ${result.changeId || changeId} — PR #${result.prNumber} (${result.state || "open"})`);
764
- if (result.url) console.log(` ${result.url}`);
765
768
  return;
766
769
  }
767
770
  const msg = result?.message || (result?.raw && String(result.raw)) || JSON.stringify(result ?? null);
@@ -815,6 +818,7 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
815
818
  shipped: status?.shipped ?? null,
816
819
  dispatched: status?.dispatched ?? null,
817
820
  notDispatched: status?.notDispatched ?? false,
821
+ forwardFailed: status?.forwardFailed ?? false,
818
822
  delivery: status?.delivery ?? null,
819
823
  previewPrUrl,
820
824
  ...(error ? { error } : {}),
@@ -1005,7 +1009,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1005
1009
  if (out.trim()) console.error(redactUrl(out.trim()));
1006
1010
  } catch (pushErr) {
1007
1011
  const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
1008
- console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
1012
+ // This is the NO-SESSION path pushing the clone-time embedded credential
1013
+ // which rotation kills the moment any fresh mint happens elsewhere. An auth
1014
+ // failure here is therefore almost always "you're not signed in IN THIS
1015
+ // SHELL", not a network problem; the old remote-is-reachable hint sent a
1016
+ // human down the wrong path live (Trello-13075 polish).
1017
+ const hint = isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr)
1018
+ ? "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"
1019
+ : "check your commit and that the checkout's remote is reachable, then re-run";
1020
+ console.error(fail(msg, hint));
1009
1021
  emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
1010
1022
  return 1;
1011
1023
  }
@@ -1013,7 +1025,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1013
1025
  printChangeSummary(changeSummary, { quiet: args.json });
1014
1026
  const note = e instanceof AuthUnavailableError
1015
1027
  ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1016
- : `couldn't reach Token of Trust for the result read-back: ${String(e?.message || e)}`;
1028
+ : `couldn't reach Token of Trust for the result read-back: ${describeReadbackError(e)}`;
1017
1029
  if (!args.json) {
1018
1030
  console.log(` (${note})`);
1019
1031
  console.log(` Your push is in; the preview updates once reconcile completes.`);
@@ -1154,14 +1166,14 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1154
1166
  }
1155
1167
  // --json also skips the browser auto-open (open: !args.noOpen && !args.json)
1156
1168
  // — automation doesn't want a browser popping up.
1157
- reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit, ref, verb });
1169
+ reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit, ref, verb, noChanges: patchEntries.length === 0 });
1158
1170
  emitJson(args, buildJsonResult({ ok: status?.status !== "failed", ref, commit, changeId, candidate, status, previewPrUrl }));
1159
1171
  return status?.status === "failed" ? 1 : 0;
1160
1172
  } catch (e) {
1161
1173
  progress?.stop();
1162
1174
  const note = e instanceof AuthUnavailableError
1163
1175
  ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1164
- : `reconcile is running — the result read-back isn't available yet: ${String(e?.message || e)}`;
1176
+ : `reconcile is running — the result read-back isn't available yet: ${describeReadbackError(e)}`;
1165
1177
  if (!args.json) {
1166
1178
  console.log(` (${note})`);
1167
1179
  console.log(` Your push is in; the preview updates once reconcile completes.`);
@@ -1247,6 +1259,19 @@ export async function pollPreviewStatus(
1247
1259
  if (last.dispatched) everDispatched = true;
1248
1260
  last.everDispatched = everDispatched;
1249
1261
  if (onTick) onTick(last, i);
1262
+ // Terminally-failed forward: the delivery record settled with forwarded:false
1263
+ // (and it isn't the at-receipt `pending` marker) — the control plane could not
1264
+ // deliver this commit to the reconciler, and polling longer cannot change that.
1265
+ // Only a NEW push produces a new delivery. Stop and say so (Trello-13075
1266
+ // honesty discipline: never spin on a state that cannot progress).
1267
+ if (
1268
+ last.status === "pending" &&
1269
+ last.delivery?.actual &&
1270
+ last.delivery.actual.forwarded === false &&
1271
+ !last.delivery.actual.pending
1272
+ ) {
1273
+ return { ...last, forwardFailed: true };
1274
+ }
1250
1275
  // Never-dispatched dead-end: still pending, no delivery has EVER been observed
1251
1276
  // for this commit, and we're past the startup grace — the reconcile will never
1252
1277
  // arrive. Return honestly instead of continuing to show "still reconciling".
@@ -1306,6 +1331,34 @@ export function shareablePrUrl(base, tenant, prNumber) {
1306
1331
  return `${String(base).replace(/\/+$/, "")}/preview/${tenant}/pr/${prNumber}`;
1307
1332
  }
1308
1333
 
1334
+ /**
1335
+ * Humanize a result-read-back failure. MCP auth errors arrive as a JSON blob
1336
+ * whose `data.self_repair` carries a summary + step list — dumping that raw
1337
+ * into the terminal (observed live: a wall of escaped JSON mid-submit) buries
1338
+ * the one thing the developer needs: sign in again. Detect that shape and
1339
+ * reduce it to the summary's first sentence + the concrete next step; anything
1340
+ * else passes through unchanged. Pure — unit-tested.
1341
+ * @param {unknown} e
1342
+ * @returns {string}
1343
+ */
1344
+ export function describeReadbackError(e) {
1345
+ const msg = String(e?.message || e || "");
1346
+ const jsonStart = msg.indexOf("{");
1347
+ if (jsonStart >= 0 && msg.includes("self_repair")) {
1348
+ try {
1349
+ const body = JSON.parse(msg.slice(jsonStart));
1350
+ const repair = body?.data?.self_repair;
1351
+ const summaryFirst = String(repair?.summary || body?.message || "").split(/(?<=\.)\s/)[0];
1352
+ if (summaryFirst) {
1353
+ return `${summaryFirst} Next: run \`tot login\` in this shell (check TOT_PROFILE), then re-run.`;
1354
+ }
1355
+ } catch {
1356
+ // Not the shape we thought — fall through to the raw message.
1357
+ }
1358
+ }
1359
+ return msg;
1360
+ }
1361
+
1309
1362
  /**
1310
1363
  * Build the printed lines for the headline "share this with your reviewer" block —
1311
1364
  * the whole point of U14: on a successful preview, the SHAREABLE deep link
@@ -1349,14 +1402,24 @@ export function formatShareableUrlBlock(s, tenant) {
1349
1402
  * @param {string} tenant @param {string} [verb]
1350
1403
  * @returns {string[]}
1351
1404
  */
1352
- export function formatNotDispatchedBlock({ commit = null, ref = null } = {}, tenant, verb = "preview") {
1405
+ export function formatNotDispatchedBlock({ commit = null, ref = null, noChanges = false } = {}, tenant, verb = "preview") {
1353
1406
  const short = commit ? commit.slice(0, 9) : "(unknown commit)";
1407
+ // Empty-diff cause FIRST when we know it applies (live-testing finding: an
1408
+ // empty candidate submit structurally CANNOT build — no diff → no candidate PR
1409
+ // → no pull_request webhook — and blaming webhooks/scope for it sent a human
1410
+ // down two wrong debugging paths).
1411
+ const causes = [
1412
+ ...(noChanges
1413
+ ? [` • 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\`),`]
1414
+ : []),
1415
+ ` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
1416
+ ` • your session is scoped to a different store than the one you pushed.`,
1417
+ ];
1354
1418
  return [
1355
1419
  `\n ⚠ No reconcile was dispatched for ${short} on ${tenant}.`,
1356
1420
  ` Your push landed${ref ? ` on ${ref}` : ""}, but nothing picked it up to build a preview —`,
1357
1421
  ` re-running \`tot ${verb}\` will NOT change that. This usually means one of:`,
1358
- ` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
1359
- ` • your session is scoped to a different store than the one you pushed.`,
1422
+ ...causes,
1360
1423
  ` Next:`,
1361
1424
  ` • \`tot grants\` — confirm ${tenant} is active for you;`,
1362
1425
  ` • check the preview dashboard for ${tenant} (it will read "Last reconcile: never" until a job runs);`,
@@ -1364,6 +1427,26 @@ export function formatNotDispatchedBlock({ commit = null, ref = null } = {}, ten
1364
1427
  ];
1365
1428
  }
1366
1429
 
1430
+ /**
1431
+ * The honest "the delivery FAILED to forward" block — the delivery record settled
1432
+ * `forwarded: false` (not the at-receipt pending marker), so the control plane
1433
+ * could not deliver this commit to the reconciler; polling longer cannot change
1434
+ * that, and ONLY a new push produces a new delivery. Distinct from
1435
+ * `formatNotDispatchedBlock` (nothing was ever dispatched) — here the plumbing
1436
+ * fired and died in transit, so the recovery differs. Pure — unit-tested.
1437
+ * @param {{ commit?: string|null }} ctx @param {string} tenant
1438
+ * @returns {string[]}
1439
+ */
1440
+ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
1441
+ const short = commit ? commit.slice(0, 9) : "(unknown commit)";
1442
+ return [
1443
+ `\n ⚠ The reconcile delivery for ${short} on ${tenant} FAILED in transit (network/timeout at the control plane).`,
1444
+ ` Waiting longer will not help — only a NEW push produces a new delivery.`,
1445
+ ` Next: commit again (an empty commit works: git commit --allow-empty -m retry) and re-push;`,
1446
+ ` if it fails the same way twice, share this with support: commit ${short}, tenant ${tenant}.`,
1447
+ ];
1448
+ }
1449
+
1367
1450
  /**
1368
1451
  * Print the reconcile/compliance/preview result and, on a clean reconcile with a
1369
1452
  * preview URL, open it in the browser (unless opts.open === false). `quiet`
@@ -1372,7 +1455,7 @@ export function formatNotDispatchedBlock({ commit = null, ref = null } = {}, ten
1372
1455
  * --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
1373
1456
  * feed the honest never-dispatched block.
1374
1457
  */
1375
- function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview" } = {}) {
1458
+ function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
1376
1459
  if (!s || s.status === "unknown") {
1377
1460
  if (!quiet) {
1378
1461
  console.log(
@@ -1382,9 +1465,16 @@ function reportStatus(s, tenant, { open = true, quiet = false, commit = null, re
1382
1465
  }
1383
1466
  return;
1384
1467
  }
1468
+ // Terminally-failed forward — the delivery fired and died in transit; a re-push
1469
+ // (new delivery) is the only recovery. Checked BEFORE notDispatched: a settled
1470
+ // failed forward IS a dispatch, just a doomed one.
1471
+ if (s.forwardFailed) {
1472
+ if (!quiet) for (const line of formatForwardFailedBlock({ commit }, tenant)) console.log(line);
1473
+ return;
1474
+ }
1385
1475
  // Never-dispatched dead-end — the honest replacement for false "still reconciling".
1386
1476
  if (s.notDispatched) {
1387
- if (!quiet) for (const line of formatNotDispatchedBlock({ commit, ref }, tenant, verb)) console.log(line);
1477
+ if (!quiet) for (const line of formatNotDispatchedBlock({ commit, ref, noChanges }, tenant, verb)) console.log(line);
1388
1478
  return;
1389
1479
  }
1390
1480
  if (s.status === "pending") {