@tokenoftrust/cli 2.4.9 → 2.4.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth.mjs +6 -2
- package/src/commands/ship.mjs +33 -8
- package/src/viewer-session.mjs +79 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.10",
|
|
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/auth.mjs
CHANGED
|
@@ -136,7 +136,7 @@ export function warnLegacyOperatorEnv(env = process.env, warn = (m) => console.e
|
|
|
136
136
|
* @param {(import("./mcp.mjs").createMcpClient extends (...a:any)=>infer R ? R : any)|null} client
|
|
137
137
|
* @param {NodeJS.ProcessEnv} env
|
|
138
138
|
* @param {{ fetchImpl?: typeof fetch, now?: number }} [deps] injectable for tests
|
|
139
|
-
* @returns {Promise<{ identity: "developer", appDomain: null, token: string, email?: string|null }>}
|
|
139
|
+
* @returns {Promise<{ identity: "developer", appDomain: null, token: string, email?: string|null, mcpUrl?: string|null }>}
|
|
140
140
|
*/
|
|
141
141
|
export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
142
142
|
const { fetchImpl = fetch, now = Date.now() } = deps;
|
|
@@ -204,6 +204,10 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
|
204
204
|
appDomain: null,
|
|
205
205
|
token: creds.accessToken,
|
|
206
206
|
email: emailFromJwt(creds.accessToken),
|
|
207
|
+
// The MCP this developer is signed in to — so a caller can tell the human
|
|
208
|
+
// which MCP their OWN session is against when a store reports it trusts a
|
|
209
|
+
// different one (the tier-mismatch case: signed in to qa, store trusts prod).
|
|
210
|
+
mcpUrl: creds.mcpUrl || null,
|
|
207
211
|
};
|
|
208
212
|
}
|
|
209
213
|
|
|
@@ -228,7 +232,7 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
|
|
|
228
232
|
*
|
|
229
233
|
* @param {ReturnType<import("./mcp.mjs").createMcpClient>} client
|
|
230
234
|
* @param {{ env?: NodeJS.ProcessEnv, initialize?: () => Promise<any>, prefer?: string }} [opts]
|
|
231
|
-
* @returns {Promise<{ identity: "developer", appDomain: null, token: string, email?: string|null }>}
|
|
235
|
+
* @returns {Promise<{ identity: "developer", appDomain: null, token: string, email?: string|null, mcpUrl?: string|null }>}
|
|
232
236
|
*/
|
|
233
237
|
export async function establishSession(client, opts = {}) {
|
|
234
238
|
const env = opts.env || process.env;
|
package/src/commands/ship.mjs
CHANGED
|
@@ -62,6 +62,7 @@ import { emitActivity } from "../activity.mjs";
|
|
|
62
62
|
// `tot preview doctor` run.
|
|
63
63
|
import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
|
|
64
64
|
import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
65
|
+
import { missingGrantsOf, refusalHint } from "../admission-hint.mjs";
|
|
65
66
|
|
|
66
67
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
67
68
|
|
|
@@ -76,6 +77,8 @@ const USAGE = `tot ship — publish the tenant's CURRENT GREEN AGGREGATE to live
|
|
|
76
77
|
accept this revision's overridable quality findings,
|
|
77
78
|
record the required reason, then continue publishing
|
|
78
79
|
tot ship --no-open don't open the live URL in your browser
|
|
80
|
+
tot ship --verbose print each request's URL, HTTP status, and the
|
|
81
|
+
server's error code — never a token or cookie
|
|
79
82
|
|
|
80
83
|
Authentication: use your own \`tot login\` session if you own the store, hold
|
|
81
84
|
a live ship-on-behalf grant, or — while the store is unpaid — were invited to
|
|
@@ -108,6 +111,7 @@ export function parseShipArgs(argv) {
|
|
|
108
111
|
url: null,
|
|
109
112
|
secret: null,
|
|
110
113
|
acceptQualityReason: null,
|
|
114
|
+
verbose: false,
|
|
111
115
|
help: false,
|
|
112
116
|
};
|
|
113
117
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -118,6 +122,7 @@ export function parseShipArgs(argv) {
|
|
|
118
122
|
else if (t === "--url") a.url = argv[++i];
|
|
119
123
|
else if (t === "--secret") a.secret = argv[++i];
|
|
120
124
|
else if (t === "--accept-quality-findings") a.acceptQualityReason = argv[++i];
|
|
125
|
+
else if (t === "--verbose") a.verbose = true;
|
|
121
126
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
122
127
|
}
|
|
123
128
|
return a;
|
|
@@ -423,9 +428,9 @@ async function readJsonSafe(res) {
|
|
|
423
428
|
*
|
|
424
429
|
* @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
|
|
425
430
|
* yes?:boolean, noOpen?:boolean, acceptQualityReason?:string|null,
|
|
426
|
-
* env?:NodeJS.ProcessEnv }} params
|
|
431
|
+
* verbose?:boolean, env?:NodeJS.ProcessEnv }} params
|
|
427
432
|
* @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
|
|
428
|
-
* openUrl?:(u:string)=>boolean, progress?:boolean,
|
|
433
|
+
* openUrl?:(u:string)=>boolean, progress?:boolean, log?:(s:string)=>void,
|
|
429
434
|
* resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
|
|
430
435
|
* @returns {Promise<number>} process exit code
|
|
431
436
|
*/
|
|
@@ -437,6 +442,7 @@ export async function runShip(
|
|
|
437
442
|
yes = false,
|
|
438
443
|
noOpen = false,
|
|
439
444
|
acceptQualityReason = null,
|
|
445
|
+
verbose = false,
|
|
440
446
|
env = process.env,
|
|
441
447
|
},
|
|
442
448
|
deps = {},
|
|
@@ -444,6 +450,9 @@ export async function runShip(
|
|
|
444
450
|
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
445
451
|
const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
|
|
446
452
|
const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
|
|
453
|
+
// --verbose: request URL + HTTP status + the server's error code only — NEVER
|
|
454
|
+
// a token, secret, or cookie value.
|
|
455
|
+
const vlog = verbose ? (deps.log || ((s) => console.error(`[verbose] ${s}`))) : () => {};
|
|
447
456
|
let base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
448
457
|
let authHeaders;
|
|
449
458
|
if (secret) {
|
|
@@ -453,7 +462,7 @@ export async function runShip(
|
|
|
453
462
|
"x-tot-capability": "ship-on-behalf",
|
|
454
463
|
};
|
|
455
464
|
} else {
|
|
456
|
-
const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl });
|
|
465
|
+
const viewer = await resolveViewer({ tenant, env, fetchImpl, storefrontUrl, verbose, log: vlog });
|
|
457
466
|
if (!viewer.ok) {
|
|
458
467
|
console.error(fail(viewer.message, viewer.hint));
|
|
459
468
|
return 2;
|
|
@@ -472,6 +481,7 @@ export async function runShip(
|
|
|
472
481
|
|
|
473
482
|
// 1. GET the read-only plan: the pinned sha + artifact digest + included PRs +
|
|
474
483
|
// rollback target + paywall verdict. Zero side effects.
|
|
484
|
+
vlog(`GET ${base}/api/changes/ship`);
|
|
475
485
|
let planRes;
|
|
476
486
|
try {
|
|
477
487
|
planRes = await fetchImpl(`${base}/api/changes/ship`, { method: "GET", headers: authHeaders });
|
|
@@ -481,21 +491,35 @@ export async function runShip(
|
|
|
481
491
|
);
|
|
482
492
|
return 1;
|
|
483
493
|
}
|
|
494
|
+
vlog(`ship plan → HTTP ${planRes.status}`);
|
|
484
495
|
const planData = await readJsonSafe(planRes);
|
|
485
|
-
if (!planRes.ok
|
|
486
|
-
|
|
487
|
-
|
|
496
|
+
if (verbose && !planRes.ok) {
|
|
497
|
+
const code = planData?.error || planData?.admission?.missing?.map((m) => m?.grant).join(",") || null;
|
|
498
|
+
if (code) vlog(`ship plan error: ${code}`);
|
|
499
|
+
}
|
|
500
|
+
if (planRes.status === 401 || planRes.status === 403) {
|
|
501
|
+
// The admission rule's own refusal (body shape `{ ok:false, error,
|
|
502
|
+
// admission:{ missing } }`) — name exactly what's missing instead of a
|
|
503
|
+
// generic "check the operator secret", which is actively wrong for the
|
|
504
|
+
// viewer-session transport (no operator secret involved at all).
|
|
488
505
|
const msg = planData?.error || `HTTP ${planRes.status}`;
|
|
489
506
|
console.error(
|
|
490
507
|
fail(
|
|
491
508
|
`the ship plan was refused: ${msg}`,
|
|
492
|
-
|
|
509
|
+
secret
|
|
493
510
|
? "check the operator secret and that it's authorised for this tenant"
|
|
494
|
-
:
|
|
511
|
+
: refusalHint(planRes.status, missingGrantsOf(planData)),
|
|
495
512
|
),
|
|
496
513
|
);
|
|
497
514
|
return 1;
|
|
498
515
|
}
|
|
516
|
+
if (!planRes.ok && planData?.ok !== false) {
|
|
517
|
+
// A transport-level refusal the endpoint didn't explain in its own shape
|
|
518
|
+
// (tenant-resolution/network) — surface it honestly.
|
|
519
|
+
const msg = planData?.error || `HTTP ${planRes.status}`;
|
|
520
|
+
console.error(fail(`the ship plan was refused: ${msg}`, "check --tenant / --url, then re-run"));
|
|
521
|
+
return 1;
|
|
522
|
+
}
|
|
499
523
|
const plan = normalizeShipPlan(planData);
|
|
500
524
|
// The tenant's mounted publication spaces ride the same release (see the file header).
|
|
501
525
|
const spaces = (await readSpaceRelease({ base, authHeaders, fetchImpl })) ?? { ready: [], waiting: [] };
|
|
@@ -726,6 +750,7 @@ export async function run(argv, ctx) {
|
|
|
726
750
|
yes: args.yes,
|
|
727
751
|
noOpen: args.noOpen,
|
|
728
752
|
acceptQualityReason: args.acceptQualityReason,
|
|
753
|
+
verbose: args.verbose,
|
|
729
754
|
env,
|
|
730
755
|
},
|
|
731
756
|
{ openUrl: (u) => openBrowser(u) },
|
package/src/viewer-session.mjs
CHANGED
|
@@ -44,6 +44,66 @@ export function parseSessionCookie(setCookie) {
|
|
|
44
44
|
return m ? m[1] : null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** @param {string|null|undefined} url @returns {string|null} */
|
|
48
|
+
function originOf(url) {
|
|
49
|
+
if (!url) return null;
|
|
50
|
+
try {
|
|
51
|
+
return new URL(url).origin;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Turn a non-2xx `/api/dev/cli-session` response into one clean, actionable
|
|
59
|
+
* `{ message, hint }` — the ONE place this CLI interprets that endpoint's refusal
|
|
60
|
+
* shape, so ship/preview-token (both routed through `resolveViewerTransport`)
|
|
61
|
+
* never duplicate or drift from each other.
|
|
62
|
+
*
|
|
63
|
+
* - 401 `unresolved_token` carrying `trustedMcp`, where the CLI's OWN signed-in
|
|
64
|
+
* MCP (`signedInMcpUrl`, from `resolveDeveloperSession`) is a DIFFERENT origin
|
|
65
|
+
* — the genuine tier mismatch (signed in to qa, store trusts prod, or vice
|
|
66
|
+
* versa): say exactly which two origins disagree and the exact fix.
|
|
67
|
+
* - Any other 401 — a real "you're not recognized here" — keeps the plain
|
|
68
|
+
* login hint, but still surfaces the server's error code so a report isn't
|
|
69
|
+
* just "it didn't work".
|
|
70
|
+
* - 502/503 — the SEAM is broken (unconfigured, unreachable, malformed), which
|
|
71
|
+
* is never a login problem: say so, with the server's code, and suggest a
|
|
72
|
+
* retry instead of `tot login`.
|
|
73
|
+
*
|
|
74
|
+
* @param {number} status
|
|
75
|
+
* @param {Record<string, unknown>|null} body
|
|
76
|
+
* @param {string|null|undefined} signedInMcpUrl — this CLI's own `creds.mcpUrl`
|
|
77
|
+
* @returns {{ message: string, hint: string }}
|
|
78
|
+
*/
|
|
79
|
+
export function describeCliSessionRefusal(status, body, signedInMcpUrl) {
|
|
80
|
+
const error = body && typeof body.error === "string" ? body.error : null;
|
|
81
|
+
const code = body && typeof body.code === "string" ? body.code : null;
|
|
82
|
+
const trustedMcp = body && typeof body.trustedMcp === "string" ? body.trustedMcp : null;
|
|
83
|
+
|
|
84
|
+
if (status === 502 || status === 503) {
|
|
85
|
+
return {
|
|
86
|
+
message: `couldn't verify your session — the store's identity check failed${code ? ` (${code})` : ""}.`,
|
|
87
|
+
hint: "retry shortly; if it persists, report it with `tot feedback`.",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (error === "unresolved_token" && trustedMcp) {
|
|
92
|
+
const signedInOrigin = originOf(signedInMcpUrl);
|
|
93
|
+
if (signedInOrigin && signedInOrigin !== trustedMcp) {
|
|
94
|
+
return {
|
|
95
|
+
message: `you're signed in to ${signedInOrigin}, but this store trusts ${trustedMcp}.`,
|
|
96
|
+
hint: `run \`tot login --mcp ${trustedMcp}\` (or unset TOT_MCP_URL), then re-run.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
message: `your \`tot\` session wasn't recognized for this store${error && error !== "unresolved_token" ? ` (${error})` : ""}.`,
|
|
103
|
+
hint: "run `tot login`, then re-run.",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
47
107
|
/**
|
|
48
108
|
* Resolve a viewer-session transport for `tenant`. Returns
|
|
49
109
|
* { ok:true, base, authHeaders } — base = tenant host; cookie transport
|
|
@@ -54,7 +114,8 @@ export function parseSessionCookie(setCookie) {
|
|
|
54
114
|
* developer can point the same command at a non-default deployment.
|
|
55
115
|
*
|
|
56
116
|
* @param {{ tenant:string, env?:NodeJS.ProcessEnv, fetchImpl?:typeof fetch,
|
|
57
|
-
* storefrontUrl?:string|null, resolveDev?:typeof resolveDeveloperSession
|
|
117
|
+
* storefrontUrl?:string|null, resolveDev?:typeof resolveDeveloperSession,
|
|
118
|
+
* verbose?:boolean, log?:(s:string)=>void }} params
|
|
58
119
|
* @returns {Promise<
|
|
59
120
|
* { ok:true, base:string, authHeaders:Record<string,string> } |
|
|
60
121
|
* { ok:false, message:string, hint:string }
|
|
@@ -66,7 +127,12 @@ export async function resolveViewerTransport({
|
|
|
66
127
|
fetchImpl = fetch,
|
|
67
128
|
storefrontUrl = null,
|
|
68
129
|
resolveDev = resolveDeveloperSession,
|
|
130
|
+
verbose = false,
|
|
131
|
+
log = (s) => console.error(`[verbose] ${s}`),
|
|
69
132
|
}) {
|
|
133
|
+
// --verbose: request URL + HTTP status + the server's error code only — NEVER
|
|
134
|
+
// the bearer token or the minted session cookie.
|
|
135
|
+
const vlog = verbose ? log : () => {};
|
|
70
136
|
// The developer's OWN MCP token (read + silently refreshed by the resolver). No
|
|
71
137
|
// client needed — resolveDeveloperSession tolerates a null client.
|
|
72
138
|
let dev;
|
|
@@ -96,6 +162,7 @@ export async function resolveViewerTransport({
|
|
|
96
162
|
// cross-origin without a CORS preflight this worker never answers, so the
|
|
97
163
|
// anti-CSRF property is preserved exactly. Spoofing an `Origin` header instead
|
|
98
164
|
// would assert a browser context that does not exist and protect nothing.
|
|
165
|
+
vlog(`POST ${base}/api/dev/cli-session`);
|
|
99
166
|
res = await fetchImpl(`${base}/api/dev/cli-session`, {
|
|
100
167
|
method: "POST",
|
|
101
168
|
headers: {
|
|
@@ -111,12 +178,17 @@ export async function resolveViewerTransport({
|
|
|
111
178
|
hint: "check the --tenant domain / your network, then re-run.",
|
|
112
179
|
};
|
|
113
180
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
181
|
+
vlog(`cli-session → HTTP ${res.status}`);
|
|
182
|
+
if (res.status === 401 || res.status === 502 || res.status === 503) {
|
|
183
|
+
let body = null;
|
|
184
|
+
try {
|
|
185
|
+
body = await res.json();
|
|
186
|
+
} catch {
|
|
187
|
+
/* non-JSON error body — describeCliSessionRefusal falls back cleanly */
|
|
188
|
+
}
|
|
189
|
+
if (body?.error || body?.code) vlog(`cli-session error: ${[body.error, body.code].filter(Boolean).join(" / ")}`);
|
|
190
|
+
const { message, hint } = describeCliSessionRefusal(res.status, body, dev.mcpUrl);
|
|
191
|
+
return { ok: false, message, hint };
|
|
120
192
|
}
|
|
121
193
|
if (!res.ok) {
|
|
122
194
|
return {
|