@tokenoftrust/cli 1.4.0-rc.17 → 1.4.0-rc.19

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.
@@ -47,6 +47,7 @@ import { fail } from "../errors.mjs";
47
47
  import { planForAction, printPlanAndConfirm } from "../plan.mjs";
48
48
  import { startProgress } from "../progress.mjs";
49
49
  import { openBrowser } from "../open.mjs";
50
+ import { emitActivity } from "../activity.mjs";
50
51
 
51
52
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
52
53
 
@@ -414,7 +415,25 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
414
415
  const shipData = await readJsonSafe(shipRes);
415
416
  progress?.stop();
416
417
 
417
- return reportShipResult(normalizeShipResult(shipData), { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
418
+ const shipResult = normalizeShipResult(shipData);
419
+ // D3: emit the ship publish lifecycle event — `tot ship` does no LOCAL git op
420
+ // (it's HTTP-orchestrated), so this marks the outcome of the publish step
421
+ // itself, distinct from the outer command's invoked/result pair. Fire-and-
422
+ // forget best-effort: a silent no-op without a hosted-bridge credential, never
423
+ // awaited, never throws, never alters the ship. `errorClass` stays low-
424
+ // cardinality (the orchestrator's own terminal state/reason, never free text).
425
+ const shipped = shipResult.state === "shipped";
426
+ void emitActivity({
427
+ action: "cli.command.result",
428
+ outcome: {
429
+ status: shipped ? "succeeded" : "failed",
430
+ ...(shipped ? {} : { errorClass: `ship_${shipResult.state}` }),
431
+ },
432
+ scope: { tenantId: tenant },
433
+ payload: { args: { command: "ship", subcommand: "ship.publish" } },
434
+ });
435
+
436
+ return reportShipResult(shipResult, { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
418
437
  }
419
438
 
420
439
  /**
@@ -50,6 +50,7 @@ import { validateTenant, ERROR } from "../validate.mjs";
50
50
  import { openBrowser } from "../open.mjs";
51
51
  import { startProgress } from "../progress.mjs";
52
52
  import { fail } from "../errors.mjs";
53
+ import { emitActivity } from "../activity.mjs";
53
54
  import {
54
55
  defaultCandidateStatePath,
55
56
  readActiveChangeId,
@@ -81,6 +82,23 @@ export function candidateRefFor(changeId) {
81
82
  return `${CANDIDATE_REF_PREFIX}${changeId}`;
82
83
  }
83
84
 
85
+ /**
86
+ * D3: emit a git-op lifecycle event for `tot submit`/`tot preview` — one per git
87
+ * operation (commit / push), carrying its own success/failure, so the timeline sees
88
+ * the individual git steps, not just the outer command's invoked/result pair. Uses
89
+ * the `cli.command.result` catalog key with a `git.<op>` subcommand (a fixed, safe
90
+ * value). Fire-and-forget best-effort: a silent no-op without a hosted-bridge
91
+ * credential, never awaited, never throws, never alters the command. `errorClass` is
92
+ * a low-cardinality class (never a raw git stderr, which can carry a token/path).
93
+ */
94
+ function emitGitOp(op, ok, { command = "submit", durationMs, errorClass } = {}) {
95
+ void emitActivity({
96
+ action: "cli.command.result",
97
+ outcome: { status: ok ? "succeeded" : "failed", ...(durationMs != null ? { durationMs } : {}), ...(errorClass ? { errorClass } : {}) },
98
+ payload: { args: { command, subcommand: `git.${op}`, ...(durationMs != null ? { durationMs } : {}) } },
99
+ });
100
+ }
101
+
84
102
  export function parseArgs(argv) {
85
103
  // `ref: null` — an explicit `--ref` always wins; otherwise the push target is
86
104
  // derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
@@ -818,6 +836,7 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
818
836
  shipped: status?.shipped ?? null,
819
837
  dispatched: status?.dispatched ?? null,
820
838
  notDispatched: status?.notDispatched ?? false,
839
+ forwardFailed: status?.forwardFailed ?? false,
821
840
  delivery: status?.delivery ?? null,
822
841
  previewPrUrl,
823
842
  ...(error ? { error } : {}),
@@ -889,11 +908,13 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
889
908
  try {
890
909
  auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
891
910
  } catch (e) {
911
+ emitGitOp("commit", false, { command: verb, errorClass: "git_commit_failed" });
892
912
  const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
893
913
  console.error(fail(msg, "commit your content manually (git add / git commit), or re-run with --no-commit"));
894
914
  emitJson(args, buildJsonResult({ ok: false, error: msg }));
895
915
  return 1;
896
916
  }
917
+ if (auto.committed) emitGitOp("commit", true, { command: verb });
897
918
  if (auto.refused) {
898
919
  console.error(
899
920
  fail(
@@ -1006,9 +1027,22 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1006
1027
  try {
1007
1028
  const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
1008
1029
  if (out.trim()) console.error(redactUrl(out.trim()));
1030
+ emitGitOp("push", true, { command: verb });
1009
1031
  } catch (pushErr) {
1032
+ emitGitOp("push", false, {
1033
+ command: verb,
1034
+ errorClass: isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr) ? "forge_auth" : "git_push_failed",
1035
+ });
1010
1036
  const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
1011
- console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
1037
+ // This is the NO-SESSION path pushing the clone-time embedded credential
1038
+ // which rotation kills the moment any fresh mint happens elsewhere. An auth
1039
+ // failure here is therefore almost always "you're not signed in IN THIS
1040
+ // SHELL", not a network problem; the old remote-is-reachable hint sent a
1041
+ // human down the wrong path live (Trello-13075 polish).
1042
+ const hint = isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr)
1043
+ ? "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"
1044
+ : "check your commit and that the checkout's remote is reachable, then re-run";
1045
+ console.error(fail(msg, hint));
1012
1046
  emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
1013
1047
  return 1;
1014
1048
  }
@@ -1016,7 +1050,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1016
1050
  printChangeSummary(changeSummary, { quiet: args.json });
1017
1051
  const note = e instanceof AuthUnavailableError
1018
1052
  ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1019
- : `couldn't reach Token of Trust for the result read-back: ${String(e?.message || e)}`;
1053
+ : `couldn't reach Token of Trust for the result read-back: ${describeReadbackError(e)}`;
1020
1054
  if (!args.json) {
1021
1055
  console.log(` (${note})`);
1022
1056
  console.log(` Your push is in; the preview updates once reconcile completes.`);
@@ -1057,7 +1091,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1057
1091
  try {
1058
1092
  const { out } = await pushPreviewRef(git, mintRemote, { ref });
1059
1093
  if (out && out.trim()) console.error(redactUrl(out.trim()));
1094
+ emitGitOp("push", true, { command: verb });
1060
1095
  } catch (e) {
1096
+ emitGitOp("push", false, {
1097
+ command: verb,
1098
+ errorClass: isForgeAuthError(e?.stderr || e?.message || e) ? "forge_auth" : "git_push_failed",
1099
+ });
1061
1100
  const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
1062
1101
  console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
1063
1102
  emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
@@ -1157,14 +1196,14 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
1157
1196
  }
1158
1197
  // --json also skips the browser auto-open (open: !args.noOpen && !args.json)
1159
1198
  // — automation doesn't want a browser popping up.
1160
- reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit, ref, verb });
1199
+ reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit, ref, verb, noChanges: patchEntries.length === 0 });
1161
1200
  emitJson(args, buildJsonResult({ ok: status?.status !== "failed", ref, commit, changeId, candidate, status, previewPrUrl }));
1162
1201
  return status?.status === "failed" ? 1 : 0;
1163
1202
  } catch (e) {
1164
1203
  progress?.stop();
1165
1204
  const note = e instanceof AuthUnavailableError
1166
1205
  ? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
1167
- : `reconcile is running — the result read-back isn't available yet: ${String(e?.message || e)}`;
1206
+ : `reconcile is running — the result read-back isn't available yet: ${describeReadbackError(e)}`;
1168
1207
  if (!args.json) {
1169
1208
  console.log(` (${note})`);
1170
1209
  console.log(` Your push is in; the preview updates once reconcile completes.`);
@@ -1250,6 +1289,19 @@ export async function pollPreviewStatus(
1250
1289
  if (last.dispatched) everDispatched = true;
1251
1290
  last.everDispatched = everDispatched;
1252
1291
  if (onTick) onTick(last, i);
1292
+ // Terminally-failed forward: the delivery record settled with forwarded:false
1293
+ // (and it isn't the at-receipt `pending` marker) — the control plane could not
1294
+ // deliver this commit to the reconciler, and polling longer cannot change that.
1295
+ // Only a NEW push produces a new delivery. Stop and say so (Trello-13075
1296
+ // honesty discipline: never spin on a state that cannot progress).
1297
+ if (
1298
+ last.status === "pending" &&
1299
+ last.delivery?.actual &&
1300
+ last.delivery.actual.forwarded === false &&
1301
+ !last.delivery.actual.pending
1302
+ ) {
1303
+ return { ...last, forwardFailed: true };
1304
+ }
1253
1305
  // Never-dispatched dead-end: still pending, no delivery has EVER been observed
1254
1306
  // for this commit, and we're past the startup grace — the reconcile will never
1255
1307
  // arrive. Return honestly instead of continuing to show "still reconciling".
@@ -1309,6 +1361,34 @@ export function shareablePrUrl(base, tenant, prNumber) {
1309
1361
  return `${String(base).replace(/\/+$/, "")}/preview/${tenant}/pr/${prNumber}`;
1310
1362
  }
1311
1363
 
1364
+ /**
1365
+ * Humanize a result-read-back failure. MCP auth errors arrive as a JSON blob
1366
+ * whose `data.self_repair` carries a summary + step list — dumping that raw
1367
+ * into the terminal (observed live: a wall of escaped JSON mid-submit) buries
1368
+ * the one thing the developer needs: sign in again. Detect that shape and
1369
+ * reduce it to the summary's first sentence + the concrete next step; anything
1370
+ * else passes through unchanged. Pure — unit-tested.
1371
+ * @param {unknown} e
1372
+ * @returns {string}
1373
+ */
1374
+ export function describeReadbackError(e) {
1375
+ const msg = String(e?.message || e || "");
1376
+ const jsonStart = msg.indexOf("{");
1377
+ if (jsonStart >= 0 && msg.includes("self_repair")) {
1378
+ try {
1379
+ const body = JSON.parse(msg.slice(jsonStart));
1380
+ const repair = body?.data?.self_repair;
1381
+ const summaryFirst = String(repair?.summary || body?.message || "").split(/(?<=\.)\s/)[0];
1382
+ if (summaryFirst) {
1383
+ return `${summaryFirst} Next: run \`tot login\` in this shell (check TOT_PROFILE), then re-run.`;
1384
+ }
1385
+ } catch {
1386
+ // Not the shape we thought — fall through to the raw message.
1387
+ }
1388
+ }
1389
+ return msg;
1390
+ }
1391
+
1312
1392
  /**
1313
1393
  * Build the printed lines for the headline "share this with your reviewer" block —
1314
1394
  * the whole point of U14: on a successful preview, the SHAREABLE deep link
@@ -1352,14 +1432,24 @@ export function formatShareableUrlBlock(s, tenant) {
1352
1432
  * @param {string} tenant @param {string} [verb]
1353
1433
  * @returns {string[]}
1354
1434
  */
1355
- export function formatNotDispatchedBlock({ commit = null, ref = null } = {}, tenant, verb = "preview") {
1435
+ export function formatNotDispatchedBlock({ commit = null, ref = null, noChanges = false } = {}, tenant, verb = "preview") {
1356
1436
  const short = commit ? commit.slice(0, 9) : "(unknown commit)";
1437
+ // Empty-diff cause FIRST when we know it applies (live-testing finding: an
1438
+ // empty candidate submit structurally CANNOT build — no diff → no candidate PR
1439
+ // → no pull_request webhook — and blaming webhooks/scope for it sent a human
1440
+ // down two wrong debugging paths).
1441
+ const causes = [
1442
+ ...(noChanges
1443
+ ? [` • 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\`),`]
1444
+ : []),
1445
+ ` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
1446
+ ` • your session is scoped to a different store than the one you pushed.`,
1447
+ ];
1357
1448
  return [
1358
1449
  `\n ⚠ No reconcile was dispatched for ${short} on ${tenant}.`,
1359
1450
  ` Your push landed${ref ? ` on ${ref}` : ""}, but nothing picked it up to build a preview —`,
1360
1451
  ` re-running \`tot ${verb}\` will NOT change that. This usually means one of:`,
1361
- ` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
1362
- ` • your session is scoped to a different store than the one you pushed.`,
1452
+ ...causes,
1363
1453
  ` Next:`,
1364
1454
  ` • \`tot grants\` — confirm ${tenant} is active for you;`,
1365
1455
  ` • check the preview dashboard for ${tenant} (it will read "Last reconcile: never" until a job runs);`,
@@ -1367,6 +1457,26 @@ export function formatNotDispatchedBlock({ commit = null, ref = null } = {}, ten
1367
1457
  ];
1368
1458
  }
1369
1459
 
1460
+ /**
1461
+ * The honest "the delivery FAILED to forward" block — the delivery record settled
1462
+ * `forwarded: false` (not the at-receipt pending marker), so the control plane
1463
+ * could not deliver this commit to the reconciler; polling longer cannot change
1464
+ * that, and ONLY a new push produces a new delivery. Distinct from
1465
+ * `formatNotDispatchedBlock` (nothing was ever dispatched) — here the plumbing
1466
+ * fired and died in transit, so the recovery differs. Pure — unit-tested.
1467
+ * @param {{ commit?: string|null }} ctx @param {string} tenant
1468
+ * @returns {string[]}
1469
+ */
1470
+ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
1471
+ const short = commit ? commit.slice(0, 9) : "(unknown commit)";
1472
+ return [
1473
+ `\n ⚠ The reconcile delivery for ${short} on ${tenant} FAILED in transit (network/timeout at the control plane).`,
1474
+ ` Waiting longer will not help — only a NEW push produces a new delivery.`,
1475
+ ` Next: commit again (an empty commit works: git commit --allow-empty -m retry) and re-push;`,
1476
+ ` if it fails the same way twice, share this with support: commit ${short}, tenant ${tenant}.`,
1477
+ ];
1478
+ }
1479
+
1370
1480
  /**
1371
1481
  * Print the reconcile/compliance/preview result and, on a clean reconcile with a
1372
1482
  * preview URL, open it in the browser (unless opts.open === false). `quiet`
@@ -1375,7 +1485,7 @@ export function formatNotDispatchedBlock({ commit = null, ref = null } = {}, ten
1375
1485
  * --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
1376
1486
  * feed the honest never-dispatched block.
1377
1487
  */
1378
- function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview" } = {}) {
1488
+ function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
1379
1489
  if (!s || s.status === "unknown") {
1380
1490
  if (!quiet) {
1381
1491
  console.log(
@@ -1385,9 +1495,16 @@ function reportStatus(s, tenant, { open = true, quiet = false, commit = null, re
1385
1495
  }
1386
1496
  return;
1387
1497
  }
1498
+ // Terminally-failed forward — the delivery fired and died in transit; a re-push
1499
+ // (new delivery) is the only recovery. Checked BEFORE notDispatched: a settled
1500
+ // failed forward IS a dispatch, just a doomed one.
1501
+ if (s.forwardFailed) {
1502
+ if (!quiet) for (const line of formatForwardFailedBlock({ commit }, tenant)) console.log(line);
1503
+ return;
1504
+ }
1388
1505
  // Never-dispatched dead-end — the honest replacement for false "still reconciling".
1389
1506
  if (s.notDispatched) {
1390
- if (!quiet) for (const line of formatNotDispatchedBlock({ commit, ref }, tenant, verb)) console.log(line);
1507
+ if (!quiet) for (const line of formatNotDispatchedBlock({ commit, ref, noChanges }, tenant, verb)) console.log(line);
1391
1508
  return;
1392
1509
  }
1393
1510
  if (s.status === "pending") {
@@ -0,0 +1,55 @@
1
+ // Regression guard: the `tot` CLI must never print/reference a raw Gitea
2
+ // forge URL to a developer's terminal. Gitea's API returns `html_url` when a
3
+ // PR is opened (e.g. `https://git.tokenoftrust.com/storefront/<repo>/pulls/<n>`)
4
+ // -- the CLI must surface the storefront-owned `/preview/<tenant>/pr/<n>` link
5
+ // instead (see apps/storefront's matching noGiteaLinks.test.ts and its header
6
+ // for the full architecture reasoning: apps/CLI never expose the forge
7
+ // directly, the MCP proxies every read/write). Precipitating incident
8
+ // (2026-08-18): a raw git.tokenoftrust.com PR URL reached an owner reviewing
9
+ // tokenoftrust.com. Test fixtures are exempt (they legitimately mock a Gitea
10
+ // URL to test the forge client), everything else in `src` must stay clean.
11
+ import { readdirSync, readFileSync, statSync } from "node:fs";
12
+ import { join, relative, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { test } from "node:test";
15
+ import assert from "node:assert/strict";
16
+
17
+ const SELF = fileURLToPath(import.meta.url);
18
+ const SRC_ROOT = dirname(SELF);
19
+
20
+ const TEST_FILE_RE = /\.test\.[cm]?js$/;
21
+ const FORGE_HOST_RE = /\bgit\.tokenoftrust\.com\b/i;
22
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]);
23
+
24
+ function walk(dir, out = []) {
25
+ for (const entry of readdirSync(dir)) {
26
+ if (SKIP_DIRS.has(entry)) continue;
27
+ const full = join(dir, entry);
28
+ const st = statSync(full);
29
+ if (st.isDirectory()) {
30
+ walk(full, out);
31
+ } else if (/\.[cm]?js$/.test(entry)) {
32
+ out.push(full);
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ test("no Gitea forge links in the CLI source outside test fixtures", () => {
39
+ const offenders = [];
40
+ for (const file of walk(SRC_ROOT)) {
41
+ if (TEST_FILE_RE.test(file)) continue;
42
+ if (file === SELF) continue;
43
+ const content = readFileSync(file, "utf8");
44
+ content.split("\n").forEach((line, i) => {
45
+ if (FORGE_HOST_RE.test(line)) {
46
+ offenders.push(`${relative(SRC_ROOT, file)}:${i + 1}: ${line.trim()}`);
47
+ }
48
+ });
49
+ }
50
+ assert.deepEqual(
51
+ offenders,
52
+ [],
53
+ `Found Gitea forge links in non-test CLI source:\n${offenders.join("\n")}`,
54
+ );
55
+ });