@tokenoftrust/cli 2.3.0 → 2.4.0
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 +6 -0
- package/package.json +1 -1
- package/src/commands/status.mjs +109 -0
- package/src/commands/submit.mjs +72 -7
- package/src/tenant-scope.mjs +1 -0
package/bin/tot.mjs
CHANGED
|
@@ -61,6 +61,7 @@ const COMMANDS = [
|
|
|
61
61
|
{ invocation: "report go-live --pages <dir>", blurb: "your store's go-live readiness report, from its page-check results" },
|
|
62
62
|
{ invocation: "dev", blurb: "run your store locally with save→reload" },
|
|
63
63
|
{ invocation: "preview", blurb: "push your store to a reviewable preview" },
|
|
64
|
+
{ invocation: "status", blurb: "the pipeline verdict for your active tenant — progressing / stuck / idle, and why" },
|
|
64
65
|
{ invocation: "ship", blurb: "publish the tenant's current green aggregate live (plan → confirm → ship)" },
|
|
65
66
|
{ invocation: "accept / tot merge", blurb: "queue a PR into the preview aggregate — operator verb, no go-live (plan → confirm → integrate)" },
|
|
66
67
|
{ invocation: "sync", blurb: "fetch `preview` and merge it into your local branch (accept-conflict recovery)" },
|
|
@@ -201,6 +202,11 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
201
202
|
return run(rest, ctx);
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
if (cmd === "status") {
|
|
206
|
+
const { run } = await import("../src/commands/status.mjs");
|
|
207
|
+
return run(rest, ctx);
|
|
208
|
+
}
|
|
209
|
+
|
|
204
210
|
if (cmd === "submit" || cmd === "deploy") {
|
|
205
211
|
const { run } = await import("../src/commands/preview.mjs");
|
|
206
212
|
return run(rest, ctx, { alias: cmd });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
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",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot status` — the pipeline verdict for your active tenant (migration DAG §U4):
|
|
3
|
+
* the SAME `kind`/`reason`/`nextAction` the Publish console and `tot preview`/`tot
|
|
4
|
+
* submit` print, available on its own without pushing anything. Reads
|
|
5
|
+
* `store_status` (tenant/repo-scoped, zero live Gitea calls) and prints its
|
|
6
|
+
* `verdict` field — pipeline state comes from there, never re-derived here.
|
|
7
|
+
*
|
|
8
|
+
* Dependency-free (global fetch via mcp.mjs's client).
|
|
9
|
+
*/
|
|
10
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
11
|
+
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
12
|
+
import { fail } from "../errors.mjs";
|
|
13
|
+
import { scopeToTenant } from "../tenant-scope.mjs";
|
|
14
|
+
import { resolveRepoAndTenant } from "./branches.mjs";
|
|
15
|
+
import { formatVerdictBlock } from "./submit.mjs";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
18
|
+
|
|
19
|
+
const USAGE = `tot status — the pipeline verdict for your active tenant
|
|
20
|
+
|
|
21
|
+
tot status verdict for the current checkout's repo
|
|
22
|
+
tot status --tenant <d> verdict for a specific store (e.g. --tenant acme.com)
|
|
23
|
+
tot status --json machine-readable output
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--tenant <appDomain> the store to read (default: the current checkout / its remote)
|
|
27
|
+
--repo <repo> an explicit repo name, when it differs from --tenant
|
|
28
|
+
--mcp <url> override the MCP base URL
|
|
29
|
+
--json print the raw { verdict, ... } payload instead of prose
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string[]} argv
|
|
34
|
+
* @returns {{ tenant: string|null, repo: string|null, mcp: string|null, json: boolean, help: boolean }}
|
|
35
|
+
*/
|
|
36
|
+
function parseStatusArgs(argv) {
|
|
37
|
+
const args = /** @type {{ tenant: string|null, repo: string|null, mcp: string|null, json: boolean, help: boolean }} */ ({
|
|
38
|
+
tenant: null, repo: null, mcp: null, json: false, help: false,
|
|
39
|
+
});
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const a = argv[i];
|
|
42
|
+
if (a === "--help" || a === "-h") args.help = true;
|
|
43
|
+
else if (a === "--json") args.json = true;
|
|
44
|
+
else if (a === "--tenant") args.tenant = argv[++i] ?? null;
|
|
45
|
+
else if (a === "--repo") args.repo = argv[++i] ?? null;
|
|
46
|
+
else if (a === "--mcp") args.mcp = argv[++i] ?? null;
|
|
47
|
+
}
|
|
48
|
+
return args;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string[]} argv @param {any} ctx */
|
|
52
|
+
export async function run(argv, ctx) {
|
|
53
|
+
const env = process.env;
|
|
54
|
+
const args = parseStatusArgs(argv);
|
|
55
|
+
if (args.help) {
|
|
56
|
+
console.log(USAGE);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { repo, tenant, error } = resolveRepoAndTenant(args, ctx);
|
|
61
|
+
if (error) {
|
|
62
|
+
console.error(error);
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
67
|
+
const client = createMcpClient(baseUrl);
|
|
68
|
+
try {
|
|
69
|
+
await establishSession(client, { env });
|
|
70
|
+
const scoped = await scopeToTenant(client, /** @type {string} */ (tenant));
|
|
71
|
+
const result = await scoped.callTool("store_status", { repo, include: ["branches"] });
|
|
72
|
+
|
|
73
|
+
if (result?.status === "error" || result?.status === "invalid_input" || result?.error) {
|
|
74
|
+
console.error(
|
|
75
|
+
fail(
|
|
76
|
+
`couldn't read status for ${repo}: ${result.error || result.message || "unknown error"}`,
|
|
77
|
+
"check --tenant / --repo, then re-run",
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (args.json) {
|
|
84
|
+
console.log(JSON.stringify({ verdict: result?.verdict ?? null, tenant: repo }, null, 2));
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const verdict = result?.verdict ?? null;
|
|
89
|
+
if (!verdict) {
|
|
90
|
+
console.log(` (no pipeline verdict available for ${repo} right now.)`);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
console.log(`${repo}:`);
|
|
94
|
+
for (const line of formatVerdictBlock(verdict)) console.log(line);
|
|
95
|
+
return 0;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (e instanceof AuthUnavailableError) {
|
|
98
|
+
console.error(fail("sign in to read status", e.hint || "run `tot login`, then re-run"));
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
console.error(
|
|
102
|
+
fail(
|
|
103
|
+
`couldn't reach the status service: ${String(e?.message || e)}`,
|
|
104
|
+
"check your connection and that you're signed in, then re-run",
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/commands/submit.mjs
CHANGED
|
@@ -962,14 +962,28 @@ export function chooseChangeId({ tenant, actorKey, branch = null, active = null,
|
|
|
962
962
|
* @param {{ repo: string, changeId: string }} opts
|
|
963
963
|
* @returns {Promise<string|null>}
|
|
964
964
|
*/
|
|
965
|
+
/**
|
|
966
|
+
* The FULL candidate_status record for one candidate (changeId/prNumber/branch/headSha/
|
|
967
|
+
* state/…), tolerant of the same three response shapes `candidateStateFor` tolerates
|
|
968
|
+
* (a bare candidate, `{candidates:[…]}`, or a plain array). Null when the tool throws or
|
|
969
|
+
* names no matching candidate — never thrown. Shared by `candidateStateFor` (state only)
|
|
970
|
+
* and `submitCandidate`'s 502-despite-success recovery (the full record).
|
|
971
|
+
* @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
|
|
972
|
+
* @param {{ repo: string, changeId: string }} opts
|
|
973
|
+
*/
|
|
974
|
+
export async function fetchCandidateStatus(scoped, { repo, changeId }) {
|
|
975
|
+
const r = await scoped.callTool("candidate_status", { repo, changeId });
|
|
976
|
+
const c = Array.isArray(r)
|
|
977
|
+
? r.find((x) => x?.changeId === changeId)
|
|
978
|
+
: Array.isArray(r?.candidates)
|
|
979
|
+
? r.candidates.find((x) => x?.changeId === changeId)
|
|
980
|
+
: r;
|
|
981
|
+
return c ?? null;
|
|
982
|
+
}
|
|
983
|
+
|
|
965
984
|
export async function candidateStateFor(scoped, { repo, changeId }) {
|
|
966
985
|
try {
|
|
967
|
-
const
|
|
968
|
-
const c = Array.isArray(r)
|
|
969
|
-
? r.find((x) => x?.changeId === changeId)
|
|
970
|
-
: Array.isArray(r?.candidates)
|
|
971
|
-
? r.candidates.find((x) => x?.changeId === changeId)
|
|
972
|
-
: r;
|
|
986
|
+
const c = await fetchCandidateStatus(scoped, { repo, changeId });
|
|
973
987
|
return c && typeof c.state === "string" ? c.state : null;
|
|
974
988
|
} catch (error) {
|
|
975
989
|
recordDiagnostic(error, {
|
|
@@ -1086,6 +1100,28 @@ export async function submitCandidate(scoped, { repo, changeId, ref, headSha, ch
|
|
|
1086
1100
|
return result;
|
|
1087
1101
|
} catch (e) {
|
|
1088
1102
|
if (e instanceof CandidateRegistrationError) throw e;
|
|
1103
|
+
// "502 despite success" (migration DAG §U4): candidate_open's forge write is
|
|
1104
|
+
// idempotent and may have LANDED even though its HTTP response never reached us
|
|
1105
|
+
// (a 5xx between MCP and its own caller, a dropped connection, …). Before reporting
|
|
1106
|
+
// a fatal "candidate not created," re-read it by its stable changeId — if it's
|
|
1107
|
+
// already there with the exact ref/head we just pushed, this submit DID succeed;
|
|
1108
|
+
// report that instead of a false failure. Purely a recovery path: any error from
|
|
1109
|
+
// the re-read (including "not found," which is the ordinary case for a genuine
|
|
1110
|
+
// registration failure) falls through to the original fatal error, unchanged.
|
|
1111
|
+
const httpStatus = /** @type {any} */ (e)?.diagnostic?.httpStatus;
|
|
1112
|
+
if (typeof httpStatus === "number" && httpStatus >= 500) {
|
|
1113
|
+
const existing = await fetchCandidateStatus(scoped, { repo, changeId }).catch(() => null);
|
|
1114
|
+
if (
|
|
1115
|
+
existing
|
|
1116
|
+
&& typeof existing.prNumber === "number"
|
|
1117
|
+
&& existing.branch === ref
|
|
1118
|
+
&& existing.headSha === headSha
|
|
1119
|
+
&& !isTerminalCandidateState(existing.state)
|
|
1120
|
+
) {
|
|
1121
|
+
reportCandidate(existing, changeId, { quiet });
|
|
1122
|
+
return existing;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1089
1125
|
throw new CandidateRegistrationError(`Candidate not created: ${describeReadbackError(e)}`, e);
|
|
1090
1126
|
}
|
|
1091
1127
|
}
|
|
@@ -1134,7 +1170,7 @@ function reportCandidate(result, changeId, { quiet = false } = {}) {
|
|
|
1134
1170
|
* candidate?: {prNumber?: number, number?: number, state?: string, url?: string, headSha?: string|null}|null,
|
|
1135
1171
|
* status?: {status?: string, reconcile?: object|null, compliance?: object|null,
|
|
1136
1172
|
* previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
|
|
1137
|
-
* notDispatched?: boolean, delivery?: object|null}|null,
|
|
1173
|
+
* notDispatched?: boolean, delivery?: object|null, verdict?: object|null}|null,
|
|
1138
1174
|
* previewPrUrl?: string|null, error?: string|null, note?: string|null, noChanges?: boolean,
|
|
1139
1175
|
* degraded?: boolean, diagnosticId?: string|null }} input
|
|
1140
1176
|
*/
|
|
@@ -1160,6 +1196,7 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
|
|
|
1160
1196
|
notDispatched: status?.notDispatched ?? false,
|
|
1161
1197
|
forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
|
|
1162
1198
|
delivery: status?.delivery ?? null,
|
|
1199
|
+
verdict: status?.verdict ?? null,
|
|
1163
1200
|
previewPrUrl,
|
|
1164
1201
|
...(degraded ? { degraded: true } : {}),
|
|
1165
1202
|
...(diagnosticId ? { diagnosticId } : {}),
|
|
@@ -1868,6 +1905,10 @@ export function normalizePreviewStatus(r) {
|
|
|
1868
1905
|
shipped: r?.shipped ?? null,
|
|
1869
1906
|
delivery,
|
|
1870
1907
|
dispatched: delivery != null,
|
|
1908
|
+
// The ONE pipeline-status read every surface (console, `preview_status`, `tot
|
|
1909
|
+
// status`) shares (migration DAG §U4) — null when it could not be read. Printed
|
|
1910
|
+
// alongside the legacy fields above, never replacing them.
|
|
1911
|
+
verdict: r?.verdict ?? null,
|
|
1871
1912
|
raw: r,
|
|
1872
1913
|
};
|
|
1873
1914
|
}
|
|
@@ -2122,6 +2163,29 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
|
|
|
2122
2163
|
];
|
|
2123
2164
|
}
|
|
2124
2165
|
|
|
2166
|
+
/**
|
|
2167
|
+
* The pipeline-verdict lines (migration DAG §U4): kind, reason, next action — the SAME
|
|
2168
|
+
* verdict the Publish console and `tot status` print, so a developer never sees a
|
|
2169
|
+
* different story in two places. On `stuck`, also prints the support bundle text when
|
|
2170
|
+
* the caller fetched one (preview_status attaches it only when `contactSupport` is
|
|
2171
|
+
* true). Pure — unit-tested. Returns `[]` when there is nothing to print (no verdict,
|
|
2172
|
+
* or a verdict object that isn't shaped as expected — never throws on a malformed one).
|
|
2173
|
+
* @param {any} verdict @param {string|null} [supportBundleText]
|
|
2174
|
+
* @returns {string[]}
|
|
2175
|
+
*/
|
|
2176
|
+
export function formatVerdictBlock(verdict, supportBundleText = null) {
|
|
2177
|
+
if (!verdict || typeof verdict !== "object" || typeof verdict.kind !== "string") return [];
|
|
2178
|
+
const lines = [`\n verdict: ${verdict.kind}${verdict.reason ? ` — ${verdict.reason}` : ""}`];
|
|
2179
|
+
const next = verdict.nextAction;
|
|
2180
|
+
if (next && typeof next.label === "string") {
|
|
2181
|
+
lines.push(` next: ${next.label}${next.command ? ` (${next.command})` : ""}`);
|
|
2182
|
+
}
|
|
2183
|
+
if (verdict.kind === "stuck" && supportBundleText) {
|
|
2184
|
+
lines.push("", " — support bundle (share this with support) —", supportBundleText);
|
|
2185
|
+
}
|
|
2186
|
+
return lines;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2125
2189
|
/**
|
|
2126
2190
|
* Print the reconcile/compliance/preview result and, on a clean reconcile with a
|
|
2127
2191
|
* preview URL, open it in the browser (unless opts.open === false). `quiet`
|
|
@@ -2135,6 +2199,7 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
|
|
|
2135
2199
|
* @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
|
|
2136
2200
|
*/
|
|
2137
2201
|
function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
|
|
2202
|
+
if (!quiet) for (const line of formatVerdictBlock(s?.verdict, s?.supportBundleText)) console.log(line);
|
|
2138
2203
|
if (!s || s.status === "unknown") {
|
|
2139
2204
|
if (!quiet) {
|
|
2140
2205
|
console.log(
|