@tokenoftrust/cli 2.0.12 → 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 +22 -3
- package/package.json +1 -1
- package/src/activity-log.mjs +2 -1
- package/src/activity.mjs +1 -1
- package/src/commands/clone.mjs +6 -1
- package/src/commands/dev.mjs +12 -2
- package/src/commands/feedback.mjs +18 -8
- package/src/commands/grants.mjs +5 -1
- package/src/commands/link.mjs +9 -1
- package/src/commands/submit.mjs +148 -32
- package/src/commands/sync.mjs +4 -0
- package/src/commands/whoami.mjs +5 -1
- package/src/diagnostics.mjs +224 -0
- package/src/errors.mjs +6 -1
- package/src/mcp.mjs +213 -28
- package/src/oauth.mjs +107 -37
- package/src/open.mjs +18 -3
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
|
|
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:
|
|
366
|
+
outcome: { status: exitCode === 0 ? "succeeded" : "failed", durationMs },
|
|
351
367
|
payload: {
|
|
352
|
-
args: {
|
|
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.
|
|
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",
|
package/src/activity-log.mjs
CHANGED
|
@@ -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
|
|
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": {
|
package/src/commands/clone.mjs
CHANGED
|
@@ -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
|
package/src/commands/dev.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
...(
|
|
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
|
-
? ` +
|
|
158
|
-
: " (no activity
|
|
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;
|
package/src/commands/grants.mjs
CHANGED
|
@@ -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
|
}
|
package/src/commands/link.mjs
CHANGED
|
@@ -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
|
}
|
package/src/commands/submit.mjs
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* exits non-zero and keeps its stable retry identity. Step 3 calls
|
|
18
18
|
* the MCP `preview_status` read-back: given the commit just pushed it returns
|
|
19
19
|
* { status, reconcile, compliance, previewUrl } and we poll it while reconcile is
|
|
20
|
-
* pending. A read-back failure
|
|
20
|
+
* pending. A later read-back failure preserves the candidate receipt and returns a
|
|
21
|
+
* successful but explicitly degraded result with a diagnostic ID.
|
|
21
22
|
*
|
|
22
23
|
* Polling (E2): every call carries `waitMs` so a preview_status-aware MCP long-polls
|
|
23
24
|
* (blocks up to waitMs, waking immediately on arrival) instead of us sleeping blind
|
|
@@ -53,6 +54,11 @@ import { openBrowser } from "../open.mjs";
|
|
|
53
54
|
import { startProgress } from "../progress.mjs";
|
|
54
55
|
import { fail } from "../errors.mjs";
|
|
55
56
|
import { emitActivity } from "../activity.mjs";
|
|
57
|
+
import {
|
|
58
|
+
diagnosticReference,
|
|
59
|
+
invocationDegradedResult,
|
|
60
|
+
recordDiagnostic,
|
|
61
|
+
} from "../diagnostics.mjs";
|
|
56
62
|
import {
|
|
57
63
|
defaultCandidateStatePath,
|
|
58
64
|
readActiveChangeId,
|
|
@@ -500,7 +506,13 @@ export async function runBornRebased(client, { repo, changeId, strategy }) {
|
|
|
500
506
|
const raw = await client.callTool("candidate_refresh", { repo, changeId, strategy });
|
|
501
507
|
return normalizeRefreshResult(raw);
|
|
502
508
|
} catch (e) {
|
|
503
|
-
|
|
509
|
+
const diagnostic = recordDiagnostic(e, {
|
|
510
|
+
command: "submit", operation: "candidate_refresh", required: false, degraded: true,
|
|
511
|
+
});
|
|
512
|
+
return {
|
|
513
|
+
...normalizeRefreshResult(null), status: "error", message: String(e?.message || e),
|
|
514
|
+
degraded: true, diagnosticId: diagnostic.diagnosticId,
|
|
515
|
+
};
|
|
504
516
|
}
|
|
505
517
|
}
|
|
506
518
|
|
|
@@ -959,7 +971,10 @@ export async function candidateStateFor(client, { repo, changeId }) {
|
|
|
959
971
|
? r.candidates.find((x) => x?.changeId === changeId)
|
|
960
972
|
: r;
|
|
961
973
|
return c && typeof c.state === "string" ? c.state : null;
|
|
962
|
-
} catch {
|
|
974
|
+
} catch (error) {
|
|
975
|
+
recordDiagnostic(error, {
|
|
976
|
+
command: "submit", operation: "candidate_status", required: false, degraded: true,
|
|
977
|
+
});
|
|
963
978
|
return null;
|
|
964
979
|
}
|
|
965
980
|
}
|
|
@@ -1012,8 +1027,8 @@ const REQUIRED_ATTRIBUTION_REFUSALS = new Set([
|
|
|
1012
1027
|
]);
|
|
1013
1028
|
|
|
1014
1029
|
export class CandidateRegistrationError extends Error {
|
|
1015
|
-
constructor(message) {
|
|
1016
|
-
super(message);
|
|
1030
|
+
constructor(message, cause) {
|
|
1031
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
1017
1032
|
this.name = "CandidateRegistrationError";
|
|
1018
1033
|
}
|
|
1019
1034
|
}
|
|
@@ -1071,7 +1086,7 @@ export async function submitCandidate(client, { repo, changeId, ref, headSha, ch
|
|
|
1071
1086
|
return result;
|
|
1072
1087
|
} catch (e) {
|
|
1073
1088
|
if (e instanceof CandidateRegistrationError) throw e;
|
|
1074
|
-
throw new CandidateRegistrationError(`Candidate not created: ${describeReadbackError(e)}
|
|
1089
|
+
throw new CandidateRegistrationError(`Candidate not created: ${describeReadbackError(e)}`, e);
|
|
1075
1090
|
}
|
|
1076
1091
|
}
|
|
1077
1092
|
|
|
@@ -1120,9 +1135,10 @@ function reportCandidate(result, changeId, { quiet = false } = {}) {
|
|
|
1120
1135
|
* status?: {status?: string, reconcile?: object|null, compliance?: object|null,
|
|
1121
1136
|
* previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
|
|
1122
1137
|
* notDispatched?: boolean, delivery?: object|null}|null,
|
|
1123
|
-
* previewPrUrl?: string|null, error?: string|null, note?: string|null, noChanges?: boolean
|
|
1138
|
+
* previewPrUrl?: string|null, error?: string|null, note?: string|null, noChanges?: boolean,
|
|
1139
|
+
* degraded?: boolean, diagnosticId?: string|null }} input
|
|
1124
1140
|
*/
|
|
1125
|
-
export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null, noChanges = false }) {
|
|
1141
|
+
export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null, noChanges = false, degraded = false, diagnosticId = null }) {
|
|
1126
1142
|
return {
|
|
1127
1143
|
ok,
|
|
1128
1144
|
ref,
|
|
@@ -1145,6 +1161,8 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
|
|
|
1145
1161
|
forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
|
|
1146
1162
|
delivery: status?.delivery ?? null,
|
|
1147
1163
|
previewPrUrl,
|
|
1164
|
+
...(degraded ? { degraded: true } : {}),
|
|
1165
|
+
...(diagnosticId ? { diagnosticId } : {}),
|
|
1148
1166
|
// Honest-diagnosis parity with the human-readable formatNotDispatchedBlock: a
|
|
1149
1167
|
// `--json` caller gets the SAME "why" signal a human sees on the console. Without
|
|
1150
1168
|
// this, `candidate:null, notDispatched:true, delivery:null` reads identically for
|
|
@@ -1157,6 +1175,45 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
|
|
|
1157
1175
|
};
|
|
1158
1176
|
}
|
|
1159
1177
|
|
|
1178
|
+
export function candidateFailureOutcome({
|
|
1179
|
+
error, candidateRequired, candidate, operation, command, env,
|
|
1180
|
+
ref, commit, changeId, previewPrUrl,
|
|
1181
|
+
}) {
|
|
1182
|
+
const required = candidateRequired && !candidate;
|
|
1183
|
+
const diagnostic = recordDiagnostic(error, {
|
|
1184
|
+
command, operation, required, degraded: !required,
|
|
1185
|
+
}, env);
|
|
1186
|
+
if (required) {
|
|
1187
|
+
const detail = error instanceof CandidateRegistrationError
|
|
1188
|
+
? error.message
|
|
1189
|
+
: String(error?.message || error);
|
|
1190
|
+
const note = `push succeeded but no review candidate was created: ${detail}`;
|
|
1191
|
+
return {
|
|
1192
|
+
required,
|
|
1193
|
+
exitCode: 1,
|
|
1194
|
+
note,
|
|
1195
|
+
diagnostic,
|
|
1196
|
+
result: buildJsonResult({
|
|
1197
|
+
ok: false, ref, commit, changeId, candidate: null, error: note,
|
|
1198
|
+
diagnosticId: diagnostic.diagnosticId,
|
|
1199
|
+
}),
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
const note = error instanceof AuthUnavailableError
|
|
1203
|
+
? `candidate created; sign in to read its reconcile/compliance result — ${error.hint || "developer sign-in pending"}`
|
|
1204
|
+
: `candidate created; result read-back is degraded: ${describeReadbackError(error)}`;
|
|
1205
|
+
return {
|
|
1206
|
+
required,
|
|
1207
|
+
exitCode: 0,
|
|
1208
|
+
note,
|
|
1209
|
+
diagnostic,
|
|
1210
|
+
result: buildJsonResult({
|
|
1211
|
+
ok: true, ref, commit, changeId, candidate, previewPrUrl, note,
|
|
1212
|
+
degraded: true, diagnosticId: diagnostic.diagnosticId,
|
|
1213
|
+
}),
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1160
1217
|
/**
|
|
1161
1218
|
* The sha `preview_status` actually resolves against. The candidate the forge opened
|
|
1162
1219
|
* may carry a DIFFERENT head than the commit you pushed (it is rebuilt onto the
|
|
@@ -1272,8 +1329,10 @@ export async function run(argv, ctx, {
|
|
|
1272
1329
|
// expired. Best-effort — never blocks the actual preview on a migration hiccup.
|
|
1273
1330
|
try {
|
|
1274
1331
|
ensureTokenlessRemote(git);
|
|
1275
|
-
} catch {
|
|
1276
|
-
|
|
1332
|
+
} catch (error) {
|
|
1333
|
+
recordDiagnostic(error, {
|
|
1334
|
+
command: verb, operation: "credential_helper_install", required: false, degraded: true,
|
|
1335
|
+
}, env);
|
|
1277
1336
|
}
|
|
1278
1337
|
|
|
1279
1338
|
// How far the base has drifted since this candidate forked (unit c2), hoisted to the
|
|
@@ -1455,11 +1514,20 @@ export async function run(argv, ctx, {
|
|
|
1455
1514
|
try {
|
|
1456
1515
|
session = await establish(client, { env, prefer: args.identity || undefined });
|
|
1457
1516
|
} catch (e) {
|
|
1517
|
+
const diagnostic = recordDiagnostic(e, {
|
|
1518
|
+
command: verb, operation: "session_establish", required: true, degraded: false,
|
|
1519
|
+
}, env);
|
|
1458
1520
|
const note = e instanceof AuthUnavailableError
|
|
1459
1521
|
? `Candidate not created: ${e.hint || "developer sign-in is required"}.`
|
|
1460
1522
|
: `Candidate not created: couldn't reach Token of Trust (${describeReadbackError(e)}).`;
|
|
1461
|
-
if (!args.json)
|
|
1462
|
-
|
|
1523
|
+
if (!args.json) {
|
|
1524
|
+
console.error(fail(note, "run `tot login`, then re-run; no candidate ref was pushed"));
|
|
1525
|
+
console.error(` → ${diagnosticReference(diagnostic, env)}`);
|
|
1526
|
+
}
|
|
1527
|
+
emitJson(args, buildJsonResult({
|
|
1528
|
+
ok: false, commit, candidate: null, error: note,
|
|
1529
|
+
diagnosticId: diagnostic.diagnosticId,
|
|
1530
|
+
}));
|
|
1463
1531
|
return 1;
|
|
1464
1532
|
}
|
|
1465
1533
|
|
|
@@ -1474,7 +1542,11 @@ export async function run(argv, ctx, {
|
|
|
1474
1542
|
if (active && repo && !args.forkCandidate) {
|
|
1475
1543
|
try {
|
|
1476
1544
|
await client.callTool("client_switch", { tenant });
|
|
1477
|
-
} catch {
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
recordDiagnostic(error, {
|
|
1547
|
+
command: verb, operation: "client_switch", required: false, degraded: true,
|
|
1548
|
+
}, env);
|
|
1549
|
+
}
|
|
1478
1550
|
const resolved = await resolveActivePointer(client, { repo, active });
|
|
1479
1551
|
if (resolved.dropped) {
|
|
1480
1552
|
console.error(
|
|
@@ -1521,10 +1593,18 @@ export async function run(argv, ctx, {
|
|
|
1521
1593
|
}
|
|
1522
1594
|
try {
|
|
1523
1595
|
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
1524
|
-
} catch {
|
|
1596
|
+
} catch (error) {
|
|
1525
1597
|
const note = "Candidate not created: couldn't persist retry identity.";
|
|
1526
|
-
|
|
1527
|
-
|
|
1598
|
+
const diagnostic = recordDiagnostic(error, {
|
|
1599
|
+
command: verb, operation: "candidate_pointer_write", required: true, degraded: false,
|
|
1600
|
+
}, env);
|
|
1601
|
+
if (!args.json) {
|
|
1602
|
+
console.error(fail(note, "fix ~/.tot permissions, then re-run; no candidate ref was pushed"));
|
|
1603
|
+
console.error(` → ${diagnosticReference(diagnostic, env)}`);
|
|
1604
|
+
}
|
|
1605
|
+
emitJson(args, buildJsonResult({
|
|
1606
|
+
ok: false, ref, commit, changeId, error: note, diagnosticId: diagnostic.diagnosticId,
|
|
1607
|
+
}));
|
|
1528
1608
|
return 1;
|
|
1529
1609
|
}
|
|
1530
1610
|
|
|
@@ -1540,6 +1620,9 @@ export async function run(argv, ctx, {
|
|
|
1540
1620
|
const res = await checkout(client, { tenant, tag, cloneDir: null, redact: redactUrl });
|
|
1541
1621
|
return res.gitRemote || null;
|
|
1542
1622
|
} catch (e) {
|
|
1623
|
+
recordDiagnostic(e, {
|
|
1624
|
+
command: verb, operation: "tenant_checkout", required: false, degraded: true,
|
|
1625
|
+
}, env);
|
|
1543
1626
|
console.error(
|
|
1544
1627
|
`~ couldn't mint a fresh push credential (${redactUrl(String(e?.message || e))}) — using the checkout's remote`,
|
|
1545
1628
|
);
|
|
@@ -1558,8 +1641,14 @@ export async function run(argv, ctx, {
|
|
|
1558
1641
|
errorClass: isForgeAuthError(e?.stderr || e?.message || e) ? "forge_auth" : "git_push_failed",
|
|
1559
1642
|
});
|
|
1560
1643
|
const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
|
|
1644
|
+
const diagnostic = recordDiagnostic(new Error(msg, { cause: e }), {
|
|
1645
|
+
command: verb, operation: "git_push", required: true, degraded: false,
|
|
1646
|
+
}, env);
|
|
1561
1647
|
console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
|
|
1562
|
-
|
|
1648
|
+
if (!args.json) console.error(` → ${diagnosticReference(diagnostic, env)}`);
|
|
1649
|
+
emitJson(args, buildJsonResult({
|
|
1650
|
+
ok: false, ref, commit, changeId, error: msg, diagnosticId: diagnostic.diagnosticId,
|
|
1651
|
+
}));
|
|
1563
1652
|
return 1;
|
|
1564
1653
|
}
|
|
1565
1654
|
if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
@@ -1568,18 +1657,26 @@ export async function run(argv, ctx, {
|
|
|
1568
1657
|
// 2b + 3. open/update the PR-backed candidate, then report reconcile +
|
|
1569
1658
|
// compliance + preview URL from the MCP — reusing the session established above.
|
|
1570
1659
|
let progress = null;
|
|
1660
|
+
let candidate = null;
|
|
1661
|
+
let previewPrUrl = null;
|
|
1662
|
+
let operation = "client_switch";
|
|
1663
|
+
const candidateRequired = changedEntries.length > 0;
|
|
1571
1664
|
try {
|
|
1572
1665
|
// Bind the active tenant so preview_status/candidate_open read the right scope
|
|
1573
1666
|
// (idempotent — checkoutTenant already switched when the fresh mint succeeded).
|
|
1574
1667
|
try {
|
|
1575
1668
|
await client.callTool("client_switch", { tenant });
|
|
1576
1669
|
} catch (e) {
|
|
1577
|
-
throw new CandidateRegistrationError(
|
|
1670
|
+
throw new CandidateRegistrationError(
|
|
1671
|
+
`Candidate not created: tenant scope could not be bound (${describeReadbackError(e)}).`,
|
|
1672
|
+
e,
|
|
1673
|
+
);
|
|
1578
1674
|
}
|
|
1579
1675
|
|
|
1580
1676
|
// 2b. Register the exact ref and asserted commit that git just pushed. No file
|
|
1581
1677
|
// bytes cross MCP and the server does not reconstruct or replace these commits.
|
|
1582
|
-
|
|
1678
|
+
operation = "candidate_open";
|
|
1679
|
+
candidate = await submitCandidate(client, {
|
|
1583
1680
|
repo, changeId, ref, headSha: commit, changeSummary,
|
|
1584
1681
|
hasChanges: changedEntries.length > 0, quiet: args.json,
|
|
1585
1682
|
});
|
|
@@ -1602,6 +1699,7 @@ export async function run(argv, ctx, {
|
|
|
1602
1699
|
// never blocking the push that already landed. Gated on --skip-freshness via
|
|
1603
1700
|
// baseDrift (0 when skipped).
|
|
1604
1701
|
if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
|
|
1702
|
+
operation = "candidate_refresh";
|
|
1605
1703
|
const rebased = await runBornRebased(client, { repo, changeId, strategy: bornRebasedStrategy });
|
|
1606
1704
|
if (rebased.ok) {
|
|
1607
1705
|
for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
|
|
@@ -1619,6 +1717,7 @@ export async function run(argv, ctx, {
|
|
|
1619
1717
|
candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
|
|
1620
1718
|
previewPrUrl: freshPrUrl,
|
|
1621
1719
|
note: `born-rebased on the current base (${rebased.strategy || bornRebasedStrategy})`,
|
|
1720
|
+
...invocationDegradedResult(),
|
|
1622
1721
|
}));
|
|
1623
1722
|
return 0;
|
|
1624
1723
|
}
|
|
@@ -1639,7 +1738,7 @@ export async function run(argv, ctx, {
|
|
|
1639
1738
|
// the server-minted `previewUrl` (formatShareableUrlBlock) that only shows
|
|
1640
1739
|
// up once reconcile actually completes. Honest framing: it's printed as
|
|
1641
1740
|
// "building", never as "ready".
|
|
1642
|
-
|
|
1741
|
+
previewPrUrl =
|
|
1643
1742
|
candidate && !isTerminalCandidateState(candidate.state) && typeof candidate.prNumber === "number"
|
|
1644
1743
|
? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, candidate.prNumber)
|
|
1645
1744
|
: null;
|
|
@@ -1657,6 +1756,7 @@ export async function run(argv, ctx, {
|
|
|
1657
1756
|
}
|
|
1658
1757
|
|
|
1659
1758
|
let status;
|
|
1759
|
+
operation = "preview_status";
|
|
1660
1760
|
if (args.noWait) {
|
|
1661
1761
|
status = normalizePreviewStatus(await client.callTool("preview_status", { commit: statusSha }));
|
|
1662
1762
|
} else {
|
|
@@ -1702,22 +1802,38 @@ export async function run(argv, ctx, {
|
|
|
1702
1802
|
const noChanges = changedEntries.length === 0;
|
|
1703
1803
|
reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit: statusSha, ref, verb, noChanges });
|
|
1704
1804
|
const ok = previewSubmitSucceeded(status);
|
|
1705
|
-
|
|
1805
|
+
const degradation = invocationDegradedResult();
|
|
1806
|
+
if (degradation.degraded && !args.json) {
|
|
1807
|
+
console.log(` ~ completed with degraded optional diagnostics — ${diagnosticReference({ diagnosticId: degradation.diagnosticId }, env)}`);
|
|
1808
|
+
}
|
|
1809
|
+
emitJson(args, buildJsonResult({
|
|
1810
|
+
ok, ref, commit, changeId, candidate, status,
|
|
1811
|
+
previewPrUrl, noChanges, ...degradation,
|
|
1812
|
+
}));
|
|
1706
1813
|
return ok ? 0 : 1;
|
|
1707
1814
|
} catch (e) {
|
|
1708
1815
|
progress?.stop();
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1816
|
+
const outcome = candidateFailureOutcome({
|
|
1817
|
+
error: e, candidateRequired, candidate, operation, command: verb, env,
|
|
1818
|
+
ref, commit, changeId, previewPrUrl,
|
|
1819
|
+
});
|
|
1820
|
+
if (outcome.required) {
|
|
1821
|
+
if (!args.json) {
|
|
1822
|
+
const next = e instanceof CandidateRegistrationError
|
|
1823
|
+
? `re-run \`tot ${verb}\`; retry will reuse ${ref}`
|
|
1824
|
+
: `fix the reported MCP failure, then re-run \`tot ${verb}\``;
|
|
1825
|
+
console.error(fail(outcome.note, next));
|
|
1826
|
+
console.error(` → ${diagnosticReference(outcome.diagnostic, env)}`);
|
|
1827
|
+
}
|
|
1828
|
+
emitJson(args, outcome.result);
|
|
1829
|
+
return outcome.exitCode;
|
|
1714
1830
|
}
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
emitJson(args,
|
|
1720
|
-
return
|
|
1831
|
+
if (!args.json) {
|
|
1832
|
+
console.log(` (${outcome.note})`);
|
|
1833
|
+
console.log(` ${diagnosticReference(outcome.diagnostic, env)}`);
|
|
1834
|
+
}
|
|
1835
|
+
emitJson(args, outcome.result);
|
|
1836
|
+
return outcome.exitCode;
|
|
1721
1837
|
}
|
|
1722
1838
|
}
|
|
1723
1839
|
|
package/src/commands/sync.mjs
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
import { execFileSync } from "node:child_process";
|
|
33
33
|
import { fail } from "../errors.mjs";
|
|
34
34
|
import { ensureTokenlessRemote } from "../git-credential.mjs";
|
|
35
|
+
import { recordDiagnostic } from "../diagnostics.mjs";
|
|
35
36
|
|
|
36
37
|
/** The protected branch `tot sync` fetches + merges from by default. */
|
|
37
38
|
export const DEFAULT_SYNC_BRANCH = "preview";
|
|
@@ -173,6 +174,9 @@ export async function run(argv, ctx) {
|
|
|
173
174
|
// missing repo, with nothing pointing back here.
|
|
174
175
|
console.error(`~ could not repair this checkout's forge credentials: ${e?.message || e}`);
|
|
175
176
|
console.error(" a fetch failure below is likely this, not a missing repo.");
|
|
177
|
+
recordDiagnostic(e, {
|
|
178
|
+
command: "sync", operation: "credential_helper_install", required: false, degraded: true,
|
|
179
|
+
});
|
|
176
180
|
}
|
|
177
181
|
|
|
178
182
|
// Refuse a dirty tree up front — a merge on top of uncommitted edits is how
|