@tokenoftrust/cli 1.2.1 → 1.2.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "Token of Trust developer CLI — check out 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
@@ -132,5 +132,70 @@ export async function resolveDeveloperSession(client, env, deps = {}) {
132
132
  }
133
133
 
134
134
  if (client && typeof client.setToken === "function") client.setToken(creds.accessToken);
135
- return { identity: "developer", appDomain: null, token: creds.accessToken };
135
+ return {
136
+ identity: "developer",
137
+ appDomain: null,
138
+ token: creds.accessToken,
139
+ email: emailFromJwt(creds.accessToken),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Establish a validated session on `client`, attaching auth in the CORRECT order
145
+ * relative to the MCP handshake — the one thing every command must get right:
146
+ *
147
+ * developer → attach the cached bearer via setToken() BEFORE client.initialize(),
148
+ * so the server binds the session to this identity AT initialize time.
149
+ * An init-then-attach order leaves the session anonymous for its whole
150
+ * life on a server that only binds identity at initialize — this is the
151
+ * invited-developer "no stores you can build on" dead-end.
152
+ * operator → client.initialize() FIRST, then credential_validate, which is an
153
+ * in-session tool call that requires a completed handshake.
154
+ *
155
+ * Every command calls THIS instead of hand-ordering initialize() + resolveSession(),
156
+ * so the ordering rule lives in exactly one place. `opts.initialize` lets a caller
157
+ * inject its own handshake (to wrap the unreachable-MCP error, or pass custom
158
+ * clientInfo); it defaults to `() => client.initialize()`.
159
+ *
160
+ * @param {ReturnType<import("./mcp.mjs").createMcpClient>} client
161
+ * @param {{ env?: NodeJS.ProcessEnv, prefer?: "operator"|"developer",
162
+ * initialize?: () => Promise<any> }} [opts]
163
+ * @returns {Promise<{ identity: "operator"|"developer", appDomain: string|null, email?: string|null }>}
164
+ */
165
+ export async function establishSession(client, opts = {}) {
166
+ const env = opts.env || process.env;
167
+ const prefer = opts.prefer || (hasOperatorCreds(env) ? "operator" : "developer");
168
+ const initialize = opts.initialize || (() => client.initialize());
169
+
170
+ if (prefer === "developer") {
171
+ // Attach the bearer, THEN handshake — the handshake carries the Authorization
172
+ // header so the server binds this identity at initialize time.
173
+ const session = await resolveDeveloperSession(client, env);
174
+ await initialize();
175
+ return session;
176
+ }
177
+ // Operator: handshake FIRST, then credential_validate (an in-session tool call).
178
+ await initialize();
179
+ return resolveSession(client, { env, prefer });
180
+ }
181
+
182
+ /**
183
+ * Best-effort email from an OAuth access token when it's a JWT — for DISPLAY ONLY
184
+ * (self-diagnosing dead-ends: "signed in as <email> via <origin>"), never for trust.
185
+ * Decodes the unverified payload segment; returns null for an opaque token or one
186
+ * with no email-ish claim. Never throws.
187
+ * @param {string} token
188
+ * @returns {string|null}
189
+ */
190
+ export function emailFromJwt(token) {
191
+ try {
192
+ const seg = String(token).split(".")[1];
193
+ if (!seg) return null;
194
+ const json = JSON.parse(
195
+ Buffer.from(seg.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"),
196
+ );
197
+ return json.email || json.preferred_username || null;
198
+ } catch {
199
+ return null;
200
+ }
136
201
  }
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { execFileSync } from "node:child_process";
22
22
  import { createMcpClient } from "../mcp.mjs";
23
- import { resolveSession, AuthUnavailableError } from "../auth.mjs";
23
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
24
24
  import { CliError, fail, formatError } from "../errors.mjs";
25
25
 
26
26
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -94,8 +94,9 @@ export async function run(argv, ctx) {
94
94
  const client = createMcpClient(baseUrl);
95
95
 
96
96
  try {
97
- await client.initialize();
98
- const session = await resolveSession(client, {
97
+ // Attach auth in the right order relative to the handshake (developer bearer
98
+ // BEFORE initialize; operator credential_validate after) — see establishSession.
99
+ const session = await establishSession(client, {
99
100
  env,
100
101
  prefer: args.identity || undefined,
101
102
  });
@@ -218,10 +219,47 @@ export function normalizeStores(list) {
218
219
  .filter((r) => r.id);
219
220
  }
220
221
 
222
+ /**
223
+ * Detect whether a `client_list` tool result is an ERROR result rather than a
224
+ * genuinely empty-but-successful store list — so callers surface it (identity +
225
+ * origin + reason, per fb-1783905718950-f45zg9) instead of collapsing it to an
226
+ * empty list and dead-ending at "ask for an invite". callTool unwraps a tool
227
+ * result to its structuredContent / parsed text, so an error surfaces as a
228
+ * status other than ok/success, an `error`/`isError` field, or an unparseable
229
+ * `raw` text blob. A response that carries a resolvable store array (even empty)
230
+ * is always a success. Returns a short human reason, or null when it's not an
231
+ * error. Pure + exported so it's unit-tested without any I/O.
232
+ * @param {unknown} list
233
+ * @returns {string|null}
234
+ */
235
+ export function storeListError(list) {
236
+ if (list == null || typeof list !== "object" || Array.isArray(list)) return null;
237
+ // A resolvable store array present → it succeeded, never an error.
238
+ if (Array.isArray(list.clients) || Array.isArray(list.tenants)) return null;
239
+ const msg =
240
+ list.message ||
241
+ (typeof list.error === "string" ? list.error : list.error?.message) ||
242
+ null;
243
+ if (list.isError) return msg || "the store list request returned an error";
244
+ if (typeof list.status === "string" && !/^(ok|success)$/i.test(list.status)) {
245
+ return msg || `the store list request returned status "${list.status}"`;
246
+ }
247
+ if (list.error) return msg || "the store list request returned an error";
248
+ if (typeof list.raw === "string" && list.raw.trim()) return list.raw.trim();
249
+ return null;
250
+ }
251
+
221
252
  function printClientList(list) {
253
+ const err = storeListError(list);
222
254
  const stores = normalizeStores(list);
255
+ if (err && stores.length === 0) {
256
+ console.log(`\nCouldn't list your stores: ${err}`);
257
+ console.log("Run `tot whoami` to check your session, or `tot login` again.");
258
+ return;
259
+ }
223
260
  if (stores.length === 0) {
224
261
  console.log("\nNo stores available to build on for this identity yet.");
262
+ console.log("If you were just invited, it may still be propagating — try again in a minute.");
225
263
  if (list && !Array.isArray(list)) console.log(JSON.stringify(list, null, 2));
226
264
  return;
227
265
  }
@@ -40,7 +40,7 @@ import { Readable } from "node:stream";
40
40
  import { pipeline } from "node:stream/promises";
41
41
  import { setTimeout as delay } from "node:timers/promises";
42
42
  import { createMcpClient } from "../mcp.mjs";
43
- import { resolveSession } from "../auth.mjs";
43
+ import { establishSession } from "../auth.mjs";
44
44
  import { CliError, fail, formatError } from "../errors.mjs";
45
45
  import { openBrowser, waitForServer } from "../open.mjs";
46
46
  import {
@@ -328,8 +328,9 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
328
328
  const baseUrl = args.mcp || process.env.MCP_BASE_URL || process.env.TOT_MCP_URL || DEFAULT_MCP_URL;
329
329
  const client = providedClient || createMcpClient(baseUrl);
330
330
  if (!providedClient) {
331
- await client.initialize();
332
- await resolveSession(client, { env: process.env });
331
+ // Auth before the entitlement-gated tool call, in handshake order (developer
332
+ // bearer pre-initialize) see establishSession.
333
+ await establishSession(client, { env: process.env });
333
334
  }
334
335
  const res = await client.callTool("dev_renderer_artifact", {});
335
336
  if (!res?.url || !res?.version) {
@@ -360,8 +361,8 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
360
361
  try {
361
362
  const client = providedClient || createMcpClient(baseUrl);
362
363
  if (!providedClient) {
363
- await client.initialize();
364
- await resolveSession(client, { env: process.env });
364
+ // Auth in handshake order before the entitlement-gated tool call.
365
+ await establishSession(client, { env: process.env });
365
366
  }
366
367
  const res = await client.callTool("dev_renderer_artifact", {});
367
368
  if (!res?.url || !res?.version) {
@@ -765,8 +766,8 @@ export async function ensureRegistryLogin(image, args, { client: providedClient
765
766
  try {
766
767
  const client = providedClient || createMcpClient(baseUrl);
767
768
  if (!providedClient) {
768
- await client.initialize();
769
- await resolveSession(client, { env: process.env });
769
+ // Auth in handshake order before the entitlement-gated tool call.
770
+ await establishSession(client, { env: process.env });
770
771
  }
771
772
  const tok = await client.callTool("dev_image_pull_token", {});
772
773
  const username = tok?.username || "AWS";
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { createInterface } from "node:readline/promises";
24
24
  import { createMcpClient } from "../mcp.mjs";
25
- import { resolveSession, AuthUnavailableError } from "../auth.mjs";
25
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
26
26
  import { readActivity, formatActivity } from "../activity-log.mjs";
27
27
  import { fail } from "../errors.mjs";
28
28
 
@@ -145,8 +145,13 @@ export async function run(argv) {
145
145
  const mcpUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
146
146
  const client = createMcpClient(mcpUrl);
147
147
  try {
148
- await resolveSession(client);
149
- await client.initialize({ name: "tot-cli", version: "feedback" });
148
+ // Attach auth in the right order relative to the handshake (developer bearer
149
+ // pre-initialize, operator credential_validate post-initialize). We keep the
150
+ // feedback-specific clientInfo by injecting our own initialize.
151
+ await establishSession(client, {
152
+ env,
153
+ initialize: () => client.initialize({ name: "tot-cli", version: "feedback" }),
154
+ });
150
155
  const payload = {
151
156
  type: args.type,
152
157
  category: args.category,
@@ -50,12 +50,12 @@ import { createInterface } from "node:readline/promises";
50
50
 
51
51
  import { detectContext } from "../context.mjs";
52
52
  import { createMcpClient } from "../mcp.mjs";
53
- import { resolveSession, AuthUnavailableError } from "../auth.mjs";
53
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
54
54
  import { CliError, fail, formatError, exitCodeFor } from "../errors.mjs";
55
55
  import { openBrowser, waitForServer } from "../open.mjs";
56
56
  import { defaultLastTenantPath, readLastTenant, writeLastTenant } from "../last-tenant.mjs";
57
57
  import { collectChecks } from "./doctor.mjs";
58
- import { normalizeStores, checkoutTenant } from "./checkout.mjs";
58
+ import { normalizeStores, storeListError, checkoutTenant } from "./checkout.mjs";
59
59
  import {
60
60
  buildContainerPlan, spawnDevContainer, dockerAvailable, tryStartDocker,
61
61
  resolveDevImage, isPrivateRegistryImage, ensureRegistryLogin,
@@ -193,11 +193,17 @@ export async function run(argv, ctx) {
193
193
  // Authenticated path. Preflight (F) now that we know we're taking it — the
194
194
  // local machine checks (node/git/Docker) that the authed loop needs.
195
195
  await preflight(ctx, env);
196
- console.log(` ✓ signed in as ${session.identity}${session.appDomain ? ` (${session.appDomain})` : ""}`);
196
+ console.log(` ✓ signed in as ${describeIdentity(session)} via ${mcpOrigin(baseUrl)}`);
197
197
 
198
198
  // 3. store — auto-pick, use --tenant, use the remembered one, or choose (A4).
199
- const stores = normalizeStores(await client.callTool("client_list", {}));
200
- const tenant = await resolveTenant(stores, args, env, baseUrl);
199
+ // Keep the raw client_list so a tool ERROR (unauthenticated / not-entitled) is
200
+ // surfaced with who + where, not collapsed to an empty list (fb-...f45zg9).
201
+ const listResp = await client.callTool("client_list", {});
202
+ const stores = normalizeStores(listResp);
203
+ const tenant = await resolveTenant(stores, args, env, baseUrl, {
204
+ session,
205
+ listErr: storeListError(listResp),
206
+ });
201
207
 
202
208
  // 4. checkout → ./<tenant> (reuse an existing checkout on a re-run),
203
209
  // OVERLAPPED (C1) with prefetching the runner: the native artifact by
@@ -340,14 +346,24 @@ async function runSampleStart(args, ctx, env, startedAt) {
340
346
  * AuthUnavailableError (no creds) or a CliError (can't reach the MCP).
341
347
  */
342
348
  async function loginStep(client, env, args) {
343
- try {
344
- await client.initialize();
345
- } catch (e) {
346
- throw new CliError(`can't reach the Token of Trust MCP at ${client.mcpUrl} (${String(e?.message || e)})`, {
347
- next: "check your network, then re-run or point elsewhere with --mcp <url>",
348
- });
349
- }
350
- return resolveSession(client, { env, prefer: args.identity || undefined });
349
+ // establishSession attaches auth in the right order relative to the handshake:
350
+ // the developer bearer BEFORE initialize (so the server binds this identity at
351
+ // initialize time — otherwise the session is anonymous and client_list is empty),
352
+ // the operator credential_validate after it. We inject our own initialize so the
353
+ // unreachable-MCP case still surfaces the actionable CliError below.
354
+ return establishSession(client, {
355
+ env,
356
+ prefer: args.identity || undefined,
357
+ initialize: async () => {
358
+ try {
359
+ await client.initialize();
360
+ } catch (e) {
361
+ throw new CliError(`can't reach the Token of Trust MCP at ${client.mcpUrl} (${String(e?.message || e)})`, {
362
+ next: "check your network, then re-run — or point elsewhere with --mcp <url>",
363
+ });
364
+ }
365
+ },
366
+ });
351
367
  }
352
368
 
353
369
  /**
@@ -436,12 +452,59 @@ async function prefetchDockerLogin(client, devArgs, env) {
436
452
  if (!dockerAvailable()) await tryStartDocker();
437
453
  }
438
454
 
455
+ /** A human label for a resolved session — the email when we could read it from
456
+ * the OAuth token, else the operator app domain, else the identity kind. */
457
+ function describeIdentity(session) {
458
+ if (!session) return "an unknown identity";
459
+ if (session.email) return session.email;
460
+ if (session.identity === "operator") {
461
+ return session.appDomain ? `operator (${session.appDomain})` : "operator";
462
+ }
463
+ return "your developer identity";
464
+ }
465
+
466
+ /** The MCP host for user-facing messages (never the full /mcp URL). */
467
+ function mcpOrigin(baseUrl) {
468
+ try {
469
+ return new URL(baseUrl).host;
470
+ } catch {
471
+ return String(baseUrl);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Build the CliError for an authenticated session that surfaced NO usable store,
477
+ * making the dead-end SELF-DIAGNOSING (fb-1783905718950-f45zg9): always report who
478
+ * we're signed in as and against which MCP, and distinguish an ERRORED client_list
479
+ * (surface the reason — likely an auth/entitlement problem) from a genuinely empty
480
+ * result (invite may still be propagating, or you need one). Points at `tot whoami`.
481
+ */
482
+ function noStoresError({ session, baseUrl, listErr }) {
483
+ const who = describeIdentity(session);
484
+ const origin = mcpOrigin(baseUrl);
485
+ if (listErr) {
486
+ return new CliError(
487
+ `signed in as ${who} via ${origin}, but listing your stores failed: ${listErr}`,
488
+ { next: "run `tot whoami` to check your session, or `tot login` again — then re-run `tot start`" },
489
+ );
490
+ }
491
+ return new CliError(
492
+ `signed in as ${who} via ${origin}, but you have no stores to build on yet`,
493
+ {
494
+ next:
495
+ "if you were just invited, it may still be propagating — try again in a minute; " +
496
+ "otherwise ask your Token of Trust contact for a store invite (see `tot whoami`), " +
497
+ "or `tot start --sample` for the free local preview",
498
+ },
499
+ );
500
+ }
501
+
439
502
  /**
440
503
  * Resolve the tenant to work on from the (normalized) store list, args, and
441
504
  * the remembered last tenant (A4) — then remember whatever was decided so the
442
505
  * next bare `tot start` doesn't have to ask again.
443
506
  */
444
- async function resolveTenant(stores, args, env, baseUrl) {
507
+ async function resolveTenant(stores, args, env, baseUrl, { session = null, listErr = null } = {}) {
445
508
  const lastTenantPath = defaultLastTenantPath(env);
446
509
  const pick = pickTenant(stores, {
447
510
  explicit: args.tenant || null,
@@ -450,9 +513,7 @@ async function resolveTenant(stores, args, env, baseUrl) {
450
513
 
451
514
  let tenant;
452
515
  if (pick.kind === "none") {
453
- throw new CliError("no stores you can build on yet", {
454
- next: "ask your Token of Trust contact for a store invite, then re-run — or `tot start --sample` for the free local preview",
455
- });
516
+ throw noStoresError({ session, baseUrl, listErr });
456
517
  } else if (pick.kind === "explicit") {
457
518
  tenant = pick.tenant;
458
519
  console.log(` → your store: ${tenant} (--tenant)`);
@@ -33,7 +33,7 @@
33
33
  import { execFileSync } from "node:child_process";
34
34
  import { setTimeout as delay } from "node:timers/promises";
35
35
  import { createMcpClient } from "../mcp.mjs";
36
- import { resolveSession, AuthUnavailableError } from "../auth.mjs";
36
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
37
37
  import { validateTenant, ERROR } from "../validate.mjs";
38
38
  import { openBrowser } from "../open.mjs";
39
39
  import { fail } from "../errors.mjs";
@@ -138,8 +138,9 @@ export async function run(argv, ctx) {
138
138
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
139
139
  const client = createMcpClient(baseUrl);
140
140
  try {
141
- await client.initialize();
142
- await resolveSession(client, { env, prefer: args.identity || undefined });
141
+ // Attach auth before the first server call (developer bearer pre-initialize,
142
+ // operator credential_validate post-initialize) see establishSession.
143
+ await establishSession(client, { env, prefer: args.identity || undefined });
143
144
  // Set the active tenant so preview_status reads the right scope (it keys on
144
145
  // the session's tenant + the commit — no tenant arg of its own).
145
146
  await client.callTool("client_switch", { tenant });
@@ -10,9 +10,9 @@
10
10
  * Dependency-free.
11
11
  */
12
12
  import { defaultCredentialsPath, readCredentials, isExpired } from "../token-store.mjs";
13
- import { hasOperatorCreds, resolveDeveloperSession } from "../auth.mjs";
13
+ import { hasOperatorCreds, establishSession } from "../auth.mjs";
14
14
  import { createMcpClient } from "../mcp.mjs";
15
- import { normalizeStores } from "./checkout.mjs";
15
+ import { normalizeStores, storeListError } from "./checkout.mjs";
16
16
  import { recordServerPolicy } from "../update-check.mjs";
17
17
 
18
18
  /**
@@ -55,17 +55,22 @@ export async function run(argv, _ctx) {
55
55
  // identity or an unreachable MCP just shows the cached status above.
56
56
  try {
57
57
  const client = createMcpClient(status.mcpUrl || env.MCP_BASE_URL || env.TOT_MCP_URL || "https://mcp.tokenoftrust.com");
58
- await client.initialize();
59
- await resolveDeveloperSession(client, env);
58
+ // Developer bearer must be attached BEFORE initialize so the server binds this
59
+ // identity at handshake time — otherwise client_list resolves anonymously and
60
+ // (misleadingly) reports no stores (establishSession orders this correctly).
61
+ await establishSession(client, { env, prefer: "developer" });
60
62
  const listResp = await client.callTool("client_list", {});
61
63
  // Update-awareness Layer 2: an authed response may carry a version-support
62
64
  // policy (wire contract: `cliPolicy`). Safe no-op when absent.
63
65
  recordServerPolicy(listResp?.cliPolicy, env);
66
+ const listErr = storeListError(listResp);
64
67
  const stores = normalizeStores(listResp);
65
68
  if (stores.length) {
66
69
  console.log(` stores you can build on: ${stores.map((s) => s.id).join(", ")}`);
70
+ } else if (listErr) {
71
+ console.log(` (couldn't list your stores: ${listErr} — try \`tot login\` again)`);
67
72
  } else {
68
- console.log(" (no stores resolved yet for this identity)");
73
+ console.log(" (no stores resolved yet if you were just invited, it may still be propagating)");
69
74
  }
70
75
  } catch {
71
76
  console.log(" (couldn't reach the MCP to list your stores right now — your cached session is above)");