@tokenoftrust/cli 2.0.11 → 2.0.14

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/bin/tot.mjs CHANGED
@@ -21,6 +21,12 @@ import { printError } from "../src/errors.mjs";
21
21
  import { recordActivity, redactArgs } from "../src/activity-log.mjs";
22
22
  import { emitActivity, capRendered } from "../src/activity.mjs";
23
23
  import { maybeNotifyUpdate } from "../src/update-check.mjs";
24
+ import {
25
+ currentInvocationDiagnostics,
26
+ hasRequiredInvocationDiagnostic,
27
+ recordDiagnostic,
28
+ resetInvocationDiagnostics,
29
+ } from "../src/diagnostics.mjs";
24
30
 
25
31
  const BUILD_ORDER = ["clone", "validate", "dev", "preview"];
26
32
 
@@ -305,6 +311,7 @@ async function main() {
305
311
  const ctx = detectContext();
306
312
  const startedAt = Date.now();
307
313
  const command = cmd || "(none)";
314
+ resetInvocationDiagnostics({ command, cliVersion: VERSION });
308
315
  const subcommand = safeSubcommand(cmd, rest);
309
316
  let code = 0;
310
317
  let errMsg = null;
@@ -323,10 +330,18 @@ async function main() {
323
330
  return code;
324
331
  } catch (e) {
325
332
  errMsg = e?.message || String(e);
333
+ recordDiagnostic(e, { command, operation: command, cliVersion: VERSION, required: true });
326
334
  throw e;
327
335
  } finally {
328
- const exitCode = errMsg ? 1 : (code ?? 0);
336
+ const exitCode = errMsg ? 1 : (Number.isInteger(code) ? code : 0);
329
337
  const durationMs = Date.now() - startedAt;
338
+ if (exitCode !== 0 && !hasRequiredInvocationDiagnostic()) {
339
+ recordDiagnostic(new Error(errMsg || `command exited ${exitCode}`), {
340
+ command, operation: command, category: "handled_exit", required: true,
341
+ });
342
+ }
343
+ const diagnostics = currentInvocationDiagnostics();
344
+ const diagnosticIds = diagnostics.map((entry) => entry.diagnosticId).filter(Boolean);
330
345
  // Best-effort activity breadcrumb (never throws, never blocks). `feedback`'s own
331
346
  // free-text message is omitted — it's user-typed and belongs only in the report.
332
347
  recordActivity({
@@ -337,6 +352,7 @@ async function main() {
337
352
  code: exitCode,
338
353
  ms: durationMs,
339
354
  ...(errMsg ? { err: String(errMsg).slice(0, 200) } : {}),
355
+ ...(diagnosticIds.length ? { diagnosticId: diagnosticIds.at(-1), diagnosticIds } : {}),
340
356
  });
341
357
  // Emit `cli.command.result` (exit code + duration + a bounded/redacted
342
358
  // rendered field). The house-style error text is capped + run through the JS
@@ -347,9 +363,12 @@ async function main() {
347
363
  // when there's no credential.
348
364
  const resultEmit = emitActivity({
349
365
  action: "cli.command.result",
350
- outcome: { status: errMsg ? "failed" : "succeeded", durationMs },
366
+ outcome: { status: exitCode === 0 ? "succeeded" : "failed", durationMs },
351
367
  payload: {
352
- args: { command, ...(subcommand ? { subcommand } : {}), cliVersion: VERSION, exitCode, durationMs },
368
+ args: {
369
+ command, ...(subcommand ? { subcommand } : {}), cliVersion: VERSION,
370
+ exitCode, durationMs, ...(diagnosticIds.length ? { diagnosticId: diagnosticIds.at(-1) } : {}),
371
+ },
353
372
  ...(errMsg ? { rendered: { output: capRendered(errMsg) } } : {}),
354
373
  },
355
374
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.0.11",
3
+ "version": "2.0.14",
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",
@@ -4,7 +4,8 @@
4
4
  * (sent through the Token of Trust MCP's `feedback_submit` tool).
5
5
  *
6
6
  * ONE file, `~/.tot/activity.log` (JSON-lines), beside the credential cache. Each
7
- * line is one command invocation: { ts, v, cmd, args, code, ms, err? }. Bounded to
7
+ * line is one command invocation: { ts, v, cmd, args, code, ms, err?, diagnosticId?,
8
+ * diagnosticIds? }. Bounded to
8
9
  * the most recent MAX_ENTRIES so it never grows without limit.
9
10
  *
10
11
  * NEVER records secrets: the value after `--code` / `--token` (the single-use
package/src/activity.mjs CHANGED
@@ -63,7 +63,7 @@ export const CLI_ACTION_CATALOG = {
63
63
  renderedAllow: [],
64
64
  },
65
65
  "cli.command.result": {
66
- argsAllow: ["command", "subcommand", "cliVersion", "exitCode", "durationMs"],
66
+ argsAllow: ["command", "subcommand", "cliVersion", "exitCode", "durationMs", "diagnosticId"],
67
67
  renderedAllow: [],
68
68
  },
69
69
  "cli.obstacle.reported": {
@@ -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
  /**
@@ -55,6 +55,7 @@ import { CliError, fail, formatError } from "../errors.mjs";
55
55
  import { writeNvmrc } from "../sample.mjs";
56
56
  import { emitObstacle } from "../obstacle.mjs";
57
57
  import { splitAuthedRemote, basicAuthExtraHeader, installForgeCredentialHelper } from "../git-credential.mjs";
58
+ import { recordDiagnostic } from "../diagnostics.mjs";
58
59
 
59
60
  const execFileP = promisify(execFile);
60
61
 
@@ -364,6 +365,7 @@ async function cloneRepo(gitRemote, dir, redact) {
364
365
  await emitObstacle("clone-failed");
365
366
  throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
366
367
  next: `check the target dir is empty and you can reach the remote, then re-run`,
368
+ cause: e,
367
369
  });
368
370
  }
369
371
  // Install the HOST-SCOPED helper, not a bare `credential.helper`. A bare one is appended to
@@ -376,9 +378,12 @@ async function cloneRepo(gitRemote, dir, redact) {
376
378
  const gitSync = (/** @type {string[]} */ cargs) =>
377
379
  execFileSync("git", ["-C", dir, ...cargs], { encoding: "utf8" });
378
380
  installForgeCredentialHelper(gitSync, { host: new URL(gitRemote).host });
379
- } catch {
381
+ } catch (error) {
380
382
  // Best-effort — never fail a good clone over credential plumbing. The next
381
383
  // `tot preview`/`tot sync` installs it, and the fetch that needs it says why.
384
+ recordDiagnostic(error, {
385
+ command: "clone", operation: "credential_helper_install", required: false, degraded: true,
386
+ });
382
387
  }
383
388
  const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
384
389
  writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
@@ -53,6 +53,7 @@ import {
53
53
  import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
54
54
  import { streamDevLogs } from "../dev-logs.mjs";
55
55
  import { cockpitUrlFrom } from "../banner.mjs";
56
+ import { recordDiagnostic } from "../diagnostics.mjs";
56
57
 
57
58
  /** The published runner image (--docker fallback). Override with --image / TOT_DEV_IMAGE. */
58
59
  const DEFAULT_DEV_IMAGE =
@@ -874,7 +875,10 @@ export function probeRunnerVersion(runnerDir, { timeoutMs = 4000 } = {}) {
874
875
  });
875
876
  const v = String(out).trim().split(/\s+/)[0];
876
877
  return /^\d+\.\d+\.\d+/.test(v) ? v : null;
877
- } catch {
878
+ } catch (error) {
879
+ recordDiagnostic(error, {
880
+ command: "dev", operation: "runner_version_probe", required: false, degraded: true,
881
+ });
878
882
  return null; // old runner (no --version), timeout, or spawn failure → treat as unversioned
879
883
  }
880
884
  }
@@ -1323,7 +1327,10 @@ export async function publishedRunnerIntegrity(env, version, { timeoutMs = 2000,
1323
1327
  if (!res.ok) return null;
1324
1328
  const dist = (await res.json())?.versions?.[version]?.dist;
1325
1329
  return dist?.integrity || dist?.shasum || null;
1326
- } catch {
1330
+ } catch (error) {
1331
+ recordDiagnostic(error, {
1332
+ command: "dev", operation: "runner_integrity_probe", required: false, degraded: true,
1333
+ }, env);
1327
1334
  return null;
1328
1335
  }
1329
1336
  }
@@ -1809,6 +1816,9 @@ export async function ensureRegistryLogin(image, args, { client: providedClient
1809
1816
  });
1810
1817
  console.error(`~ registry sign-in ok (${reg})`);
1811
1818
  } catch (e) {
1819
+ recordDiagnostic(e, {
1820
+ command: "dev", operation: "dev_image_pull_token", required: false, degraded: true,
1821
+ });
1812
1822
  console.error(
1813
1823
  `~ (using existing docker login for ${registry} — MCP pull-token not available: ${String(e?.message || e)})`,
1814
1824
  );
@@ -26,6 +26,7 @@ import { establishSession, AuthUnavailableError } from "../auth.mjs";
26
26
  import { readActivity, formatActivity } from "../activity-log.mjs";
27
27
  import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
28
28
  import { fail } from "../errors.mjs";
29
+ import { formatDiagnostics, readDiagnostics, recordDiagnostic } from "../diagnostics.mjs";
29
30
 
30
31
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
31
32
  const TYPES = new Set(["bug", "feature", "improvement"]);
@@ -57,9 +58,14 @@ Requires a signed-in session — run \`tot login\` first if needed.`;
57
58
  * `traceId` link) is unit-testable without driving the MCP handshake. Both extras
58
59
  * are OMITTED when their source is absent — never sent as null/empty.
59
60
  * @param {{ type: string, category: string, severity: string, title: string,
60
- * description: string, activityText?: string|null, traceId?: string|null }} f
61
+ * description: string, activityText?: string|null, diagnosticsText?: string|null,
62
+ * traceId?: string|null }} f
61
63
  */
62
- export function buildFeedbackPayload({ type, category, severity, title, description, activityText, traceId }) {
64
+ export function buildFeedbackPayload({ type, category, severity, title, description, activityText, diagnosticsText, traceId }) {
65
+ const attachments = [
66
+ activityText ? `tot CLI activity (most recent last):\n${activityText}` : null,
67
+ diagnosticsText ? `tot CLI diagnostics (redacted, most recent last):\n${diagnosticsText}` : null,
68
+ ].filter(Boolean);
63
69
  return {
64
70
  type,
65
71
  category,
@@ -67,9 +73,9 @@ export function buildFeedbackPayload({ type, category, severity, title, descript
67
73
  title,
68
74
  description,
69
75
  scenario: "tot-cli",
70
- // Activity → `sensitive` (ToT-admins-only, never clustered/shared). It's already
71
- // secret-redacted; `sensitive` is the belt-and-suspenders home for it.
72
- ...(activityText ? { sensitive: `tot CLI activity (most recent last):\n${activityText}` } : {}),
76
+ // CLI context → `sensitive` (ToT-admins-only, never clustered/shared). It is
77
+ // redacted before attachment; `sensitive` is the belt-and-suspenders home for it.
78
+ ...(attachments.length ? { sensitive: attachments.join("\n\n") } : {}),
73
79
  // Invite→terminal→problem trace link (omitted when this wasn't an invite login).
74
80
  ...(traceId ? { traceId } : {}),
75
81
  };
@@ -138,6 +144,8 @@ export async function run(argv) {
138
144
 
139
145
  const entries = args.activity ? readActivity(env, { limit: 40 }) : [];
140
146
  const activityText = entries.length ? formatActivity(entries) : null;
147
+ const diagnostics = args.activity ? readDiagnostics(env, { limit: 20 }) : [];
148
+ const diagnosticsText = diagnostics.length ? formatDiagnostics(diagnostics) : null;
141
149
  const title = message.length > 80 ? `${message.slice(0, 79)}…` : message;
142
150
 
143
151
  // The invite→terminal→problem trace id `tot login` cached from the pasted invite
@@ -153,9 +161,9 @@ export async function run(argv) {
153
161
  console.error(` ${args.type} · ${args.category} · ${args.severity}`);
154
162
  console.error(` "${title}"`);
155
163
  console.error(
156
- activityText
157
- ? ` + your last ${entries.length} CLI command(s) — secret-redacted, ToT-admins-only`
158
- : " (no activity log attached)",
164
+ activityText || diagnosticsText
165
+ ? ` + ${entries.length} recent command(s) and ${diagnostics.length} diagnostic(s) — redacted, ToT-admins-only`
166
+ : " (no activity or diagnostics attached)",
159
167
  );
160
168
 
161
169
  if (!args.yes) {
@@ -192,6 +200,7 @@ export async function run(argv) {
192
200
  title,
193
201
  description: message,
194
202
  activityText,
203
+ diagnosticsText,
195
204
  traceId,
196
205
  });
197
206
  const res = await client.callTool("feedback_submit", payload);
@@ -199,6 +208,7 @@ export async function run(argv) {
199
208
  console.log(`\n+ sent — thank you.${id ? ` (report ${id})` : ""}`);
200
209
  return 0;
201
210
  } catch (e) {
211
+ recordDiagnostic(e, { command: "feedback", operation: "feedback_submit", required: true }, env);
202
212
  if (e instanceof AuthUnavailableError) {
203
213
  console.error(fail(`can't send feedback: ${e.message}`, e.hint || "run `tot login` first."));
204
214
  return 1;
@@ -29,6 +29,7 @@ import { createMcpClient } from "../mcp.mjs";
29
29
  import { storeListError, noStoresGuidance } from "./clone.mjs";
30
30
  import { offerSignIn } from "./login.mjs";
31
31
  import { recordServerPolicy } from "../update-check.mjs";
32
+ import { recordDiagnostic } from "../diagnostics.mjs";
32
33
 
33
34
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
34
35
 
@@ -169,7 +170,10 @@ async function tryIntrospect(client) {
169
170
  const r = await client.callTool("grant_introspect", {});
170
171
  if (r && typeof r === "object" && Array.isArray(r.grants)) return r;
171
172
  return null;
172
- } catch {
173
+ } catch (error) {
174
+ recordDiagnostic(error, {
175
+ command: "grants", operation: "grant_introspect", required: false, degraded: true,
176
+ });
173
177
  return null;
174
178
  }
175
179
  }
@@ -25,6 +25,7 @@ import { offerSignIn } from "./login.mjs";
25
25
  import { openBrowser } from "../open.mjs";
26
26
  import { CliError, fail, formatError } from "../errors.mjs";
27
27
  import { normalizeStores } from "./clone.mjs";
28
+ import { recordDiagnostic } from "../diagnostics.mjs";
28
29
 
29
30
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
30
31
 
@@ -131,6 +132,7 @@ export async function run(argv, _ctx) {
131
132
  } catch (e) {
132
133
  throw new CliError(`couldn't start the identity link: ${String(e?.message || e)}`, {
133
134
  next: "your MCP may not support `tot link` yet — run `tot whoami` for the current guidance",
135
+ cause: e,
134
136
  });
135
137
  }
136
138
  const { authUrl, pollHandle } = linkBeginFields(begin);
@@ -172,7 +174,10 @@ export async function run(argv, _ctx) {
172
174
  } else {
173
175
  console.log(" Next: `tot start` — if it still shows no stores, ask your ToT contact for a store invite.");
174
176
  }
175
- } catch {
177
+ } catch (error) {
178
+ recordDiagnostic(error, {
179
+ command: "link", operation: "client_list", required: false, degraded: true,
180
+ }, env);
176
181
  console.log(" Next: `tot start` to build your store.");
177
182
  }
178
183
  return 0;
@@ -205,6 +210,9 @@ export async function pollLink(client, pollHandle, { timeoutMs = 120000, interva
205
210
  res = await client.callTool("identity_link_poll", { pollHandle });
206
211
  } catch (e) {
207
212
  // A transient poll error isn't fatal — keep trying until the deadline.
213
+ recordDiagnostic(e, {
214
+ command: "link", operation: "identity_link_poll", required: false, degraded: true,
215
+ });
208
216
  await delay(intervalMs);
209
217
  continue;
210
218
  }