@tokenoftrust/cli 1.2.1 → 1.2.3

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.
Files changed (32) hide show
  1. package/bin/tot.mjs +1 -5
  2. package/package.json +1 -2
  3. package/src/auth.mjs +66 -1
  4. package/src/commands/checkout.mjs +42 -4
  5. package/src/commands/dev.mjs +58 -18
  6. package/src/commands/feedback.mjs +8 -3
  7. package/src/commands/start.mjs +98 -30
  8. package/src/commands/submit.mjs +8 -8
  9. package/src/commands/whoami.mjs +10 -5
  10. package/src/sample.mjs +6 -1
  11. package/template/sample-store/content/chrome.html +0 -152
  12. package/template/sample-store/content/chrome.json +0 -94
  13. package/template/sample-store/content/home.html +0 -194
  14. package/template/sample-store/content/home.json +0 -50
  15. package/template/sample-store/content/pages/about.json +0 -10
  16. package/template/sample-store/content/pages/privacy.json +0 -6
  17. package/template/sample-store/content/pages/shipping-returns.json +0 -6
  18. package/template/sample-store/content/pages-html/blogs/news.html +0 -68
  19. package/template/sample-store/content/pages-html/pages/about-us.html +0 -95
  20. package/template/sample-store/content/pages-html/pages/contact-us.html +0 -68
  21. package/template/sample-store/content/pages-html/pages/privacy-policy.html +0 -65
  22. package/template/sample-store/content/pages-html/pages/shipping-returns.html +0 -77
  23. package/template/sample-store/content/themes/giant-navy.json +0 -73
  24. package/template/sample-store/public/img/hero-suicide-bunny.jpg +0 -0
  25. package/template/sample-store/public/img/hero.webp +0 -0
  26. package/template/sample-store/public/img/og.jpg +0 -0
  27. package/template/sample-store/public/logo-wordmark.png +0 -0
  28. package/template/sample-store/public/pages/home.css +0 -120
  29. package/template/sample-store/public/pages/mkt.css +0 -185
  30. package/template/sample-store/public/pages/page.css +0 -155
  31. package/template/sample-store/public/themes/giant-navy.css +0 -76
  32. package/template/sample-store/theme.json +0 -39
package/bin/tot.mjs CHANGED
@@ -5,14 +5,12 @@
5
5
  * One command a developer learns to go from an invite to a running store:
6
6
  *
7
7
  * tot start invite → running store in one command ← built (orchestrates the below, value-first)
8
- * tot start --sample FREE local preview — no login, no MCP ← built (zero-login quickstart; MCP is the upsell)
9
8
  * tot login sign in to Token of Trust (OAuth) ← built (MCP OAuth PKCE loopback; caches ~/.tot/credentials.json)
10
9
  * tot logout sign out (clear the cached session) ← built (deletes ~/.tot/credentials.json; local-only, no server revoke)
11
10
  * tot whoami who you're signed in as ← built
12
11
  * tot checkout [<tenant>] clone a store you can build on ← built
13
12
  * tot validate lint your store before you submit ← built
14
13
  * tot dev run your store locally with save→reload ← built (monorepo: host astro; standalone: runs the published runner image)
15
- * tot dev --sample run a FREE bundled sample store locally ← built (zero-login, no MCP; compliance rendering still shows)
16
14
  * tot submit submit your store for preview ← built (validate + push preview ref; MCP preview_status read-back)
17
15
  * tot doctor check this machine is ready
18
16
  * tot ideas copy-paste AI prompts that reliably wow
@@ -24,7 +22,7 @@
24
22
  * from anywhere else. Every command receives the detected context.
25
23
  *
26
24
  * This package is `@tokenoftrust/cli` (bin `tot`). Its heaviest command, `tot dev`,
27
- * bundles the moat-free storefront runner; `tot checkout/validate/submit` are
25
+ * fetches the published storefront runner; `tot checkout/validate/submit` are
28
26
  * pure Node. Dependency-free by design so `npm i -g @tokenoftrust/cli` stays light.
29
27
  */
30
28
  import { readFileSync } from "node:fs";
@@ -49,14 +47,12 @@ function usage() {
49
47
  tot — Token of Trust developer CLI
50
48
 
51
49
  tot start invite → running store in one command (start here)
52
- tot start --sample free local preview — no login, no account needed
53
50
  tot login sign in to Token of Trust
54
51
  tot logout sign out (clear the cached session)
55
52
  tot whoami show who you're signed in as
56
53
  tot checkout [<tenant>] clone a store you can build on
57
54
  tot validate lint your store before you submit
58
55
  tot dev run your store locally with save→reload
59
- tot dev --sample run a free bundled sample store (no login, no MCP)
60
56
  tot submit submit your store for preview
61
57
  tot doctor check this machine is ready
62
58
  tot ideas copy-paste AI prompts that reliably wow
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
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",
@@ -25,7 +25,6 @@
25
25
  "files": [
26
26
  "bin",
27
27
  "src",
28
- "template",
29
28
  "README.md",
30
29
  "LICENSE"
31
30
  ],
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
  }
@@ -13,14 +13,14 @@
13
13
  * `tot` performs NO privileged forge work itself — the MCP owns that.
14
14
  *
15
15
  * Auth is resolved through src/auth.mjs: operator creds (our dogfooding / CI) or
16
- * the developer's own ToT identity (workstream #2). This command doesn't care
16
+ * the developer's own ToT identity. This command doesn't care
17
17
  * which — it just needs a validated session.
18
18
  *
19
19
  * Dependency-free (global fetch + `git` via child_process).
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
  }
@@ -8,7 +8,7 @@
8
8
  * monorepo or --workspace graft mode). Fast host loop for platform devs.
9
9
  *
10
10
  * checkout — a standalone tenant checkout has no runner. `tot dev` DOWNLOADS the
11
- * published, moat-free RUNNER ARTIFACT (a plain tarball — the exact
11
+ * published, RUNNER ARTIFACT (a plain tarball — the exact
12
12
  * same tree Docker used to bake into the image, see
13
13
  * scripts/build/build-runner.mjs) and runs it NATIVELY: no Docker, no
14
14
  * container. Cached once per version under ~/.tot/cache/renderer/, so
@@ -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 {
@@ -48,7 +48,7 @@ import {
48
48
  resolveRendererSource as resolveLocalRendererSource, SAMPLE_DIR_NAME,
49
49
  } from "../sample.mjs";
50
50
 
51
- /** The published moat-free runner image (--docker fallback). Override with --image / TOT_DEV_IMAGE. */
51
+ /** The published runner image (--docker fallback). Override with --image / TOT_DEV_IMAGE. */
52
52
  const DEFAULT_DEV_IMAGE =
53
53
  "242086487598.dkr.ecr.us-east-1.amazonaws.com/tot-dev:latest";
54
54
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -147,16 +147,56 @@ async function runStandalone(workspace, args, ctx) {
147
147
  return await runNative(workspace, args, ctx);
148
148
  } catch (e) {
149
149
  if (e instanceof NativeArtifactUnavailableError) {
150
- console.error(
151
- `~ native runtime unavailable (${e.message}) falling back to the Docker runner.`,
152
- );
153
- return runContainer(workspace, args, ctx);
150
+ // Entitled artifact unavailable — stay NATIVE on the public npm runner
151
+ // (@tokenoftrust/storefront-runner, the SAME renderer bits), so
152
+ // `tot dev` never needs Docker just because dev_renderer_artifact isn't
153
+ // configured. Docker is the last resort only if the public runner is also
154
+ // unreachable (offline).
155
+ try {
156
+ console.error(`~ entitled renderer unavailable (${e.message}) — using the public runner (no Docker).`);
157
+ return await runNativePublic(workspace, args, ctx);
158
+ } catch (e2) {
159
+ if (e2 instanceof NativeArtifactUnavailableError) {
160
+ console.error(`~ public runner unavailable (${e2.message}) — falling back to the Docker runner.`);
161
+ return runContainer(workspace, args, ctx);
162
+ }
163
+ console.error(formatError(e2));
164
+ return e2 instanceof CliError ? (e2.exitCode ?? 2) : 2;
165
+ }
154
166
  }
155
167
  console.error(formatError(e));
156
168
  return e instanceof CliError ? (e.exitCode ?? 2) : 2;
157
169
  }
158
170
  }
159
171
 
172
+ /**
173
+ * Native boot against the PUBLIC/local renderer (no MCP, no
174
+ * entitlement) — the fallback for a standalone checkout when the entitled
175
+ * artifact isn't available. Mirrors runNative but resolves the renderer via
176
+ * ensureSampleRenderer (env override → cached runner → in-tree monorepo →
177
+ * public npm), which throws NativeArtifactUnavailableError when even the public
178
+ * runner can't be fetched (offline) so the caller can drop to Docker.
179
+ */
180
+ async function runNativePublic(workspace, args, ctx) {
181
+ const cfg = readWorkspaceConfig(workspace);
182
+ if (!cfg) {
183
+ throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
184
+ next: "tot checkout <tenant> --clone <dir> (produces a runnable checkout)",
185
+ exitCode: 2,
186
+ });
187
+ }
188
+ const port = String(args.port || "4321");
189
+ const { url } = deriveUrl(cfg, port);
190
+ let runnerDir;
191
+ try {
192
+ runnerDir = await ensureSampleRenderer(args, ctx);
193
+ } catch (e) {
194
+ throw new NativeArtifactUnavailableError(String(e?.message || e));
195
+ }
196
+ printDevBanner({ tenant: cfg.tenant || null, url });
197
+ return bootNative(runnerDir, workspace, port, url, args);
198
+ }
199
+
160
200
  function runMonorepo(ctx, argv) {
161
201
  const script = join(ctx.repoRoot, "scripts", "tot-dev.mjs");
162
202
  if (!existsSync(script)) {
@@ -274,7 +314,7 @@ export function printSampleBanner({ url }) {
274
314
  }
275
315
 
276
316
  /**
277
- * Resolve WHERE to get the moat-free runner tarball for this run — the seam that
317
+ * Resolve WHERE to get the runner tarball for this run — the seam that
278
318
  * splits the entitled and the public (zero-login sample) delivery paths. Returns
279
319
  * `{ kind, version, url, strip, cacheKey }`; callers download + extract + install +
280
320
  * cache it uniformly (installRunnerTarball).
@@ -297,7 +337,7 @@ export async function resolveRendererSource(args, { client } = {}) {
297
337
  }
298
338
 
299
339
  /**
300
- * PUBLIC, un-entitled source for sample / zero-login mode: the moat-free runner
340
+ * PUBLIC, un-entitled source for sample / zero-login mode: the runner
301
341
  * straight from the public npm registry — NO MCP call, NO entitlement. Reads the
302
342
  * package's registry metadata (unauthenticated JSON) for the tarball URL + version.
303
343
  * Pin with `--renderer-version` / TOT_RUNNER_VERSION, else the `latest` dist-tag.
@@ -328,8 +368,9 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
328
368
  const baseUrl = args.mcp || process.env.MCP_BASE_URL || process.env.TOT_MCP_URL || DEFAULT_MCP_URL;
329
369
  const client = providedClient || createMcpClient(baseUrl);
330
370
  if (!providedClient) {
331
- await client.initialize();
332
- await resolveSession(client, { env: process.env });
371
+ // Auth before the entitlement-gated tool call, in handshake order (developer
372
+ // bearer pre-initialize) see establishSession.
373
+ await establishSession(client, { env: process.env });
333
374
  }
334
375
  const res = await client.callTool("dev_renderer_artifact", {});
335
376
  if (!res?.url || !res?.version) {
@@ -339,7 +380,7 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
339
380
  }
340
381
 
341
382
  /**
342
- * Get the moat-free renderer artifact for this host — a signed URL from the MCP
383
+ * Get the renderer artifact for this host — a signed URL from the MCP
343
384
  * (dev_renderer_artifact, same entitlement gate as the Docker pull token),
344
385
  * downloaded + extracted + `pnpm install`'d ONCE per version under
345
386
  * ~/.tot/cache/renderer/<version>/. Later runs of the same version reuse the
@@ -360,8 +401,8 @@ export async function ensureRendererArtifact(args, { client: providedClient } =
360
401
  try {
361
402
  const client = providedClient || createMcpClient(baseUrl);
362
403
  if (!providedClient) {
363
- await client.initialize();
364
- await resolveSession(client, { env: process.env });
404
+ // Auth in handshake order before the entitlement-gated tool call.
405
+ await establishSession(client, { env: process.env });
365
406
  }
366
407
  const res = await client.callTool("dev_renderer_artifact", {});
367
408
  if (!res?.url || !res?.version) {
@@ -751,8 +792,7 @@ export function isPrivateRegistryImage(image) {
751
792
  * Entitlement-gated registry login: ask the MCP for a short-lived pull credential
752
793
  * (`dev_image_pull_token`) and `docker login` with it. Best-effort — if the MCP,
753
794
  * the session, or the tool isn't available yet, warn softly and continue (an
754
- * existing `docker login`, or the docker pull error, takes over). This is the CLI
755
- * half of ~/.tot-mcp/handoffs/2026-07-09-tot-mcp-dev-image-pull-token.md.
795
+ * existing `docker login`, or the docker pull error, takes over).
756
796
  *
757
797
  * Exported + accepts an already-authenticated `client` (C1: `tot start` reuses
758
798
  * its own session and overlaps this with the checkout clone instead of paying
@@ -765,8 +805,8 @@ export async function ensureRegistryLogin(image, args, { client: providedClient
765
805
  try {
766
806
  const client = providedClient || createMcpClient(baseUrl);
767
807
  if (!providedClient) {
768
- await client.initialize();
769
- await resolveSession(client, { env: process.env });
808
+ // Auth in handshake order before the entitlement-gated tool call.
809
+ await establishSession(client, { env: process.env });
770
810
  }
771
811
  const tok = await client.callTool("dev_image_pull_token", {});
772
812
  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,
@@ -93,12 +93,9 @@ const USAGE = `tot start — go from invite to a running store in one command
93
93
 
94
94
  tot start preflight → sign in → pick your store → check out →
95
95
  run it → open your browser → connect Claude
96
- tot start --sample the FREE local preview — no login, no MCP, no account:
97
- scaffold + run a sample store, then offer the MCP upsell
98
96
  Options:
99
97
  --tenant <id> use this store (skips auto-pick/prompt; remembered for next time)
100
98
  --port <n> host port for the dev server (default 4321)
101
- --sample zero-login free taste (see above); also what a no-session run offers
102
99
  --docker use the Docker runner instead of the native runtime (F3
103
100
  default); also the automatic fallback if the native
104
101
  artifact can't be fetched
@@ -183,8 +180,8 @@ export async function run(argv, ctx) {
183
180
  const session = await tryResolveSession(client, env, args);
184
181
  if (decideStartMode({ sampleFlag: false, hasSession: !!session }) === "sample") {
185
182
  if (!(await confirmSampleFallback(args))) {
186
- throw new CliError("no Token of Trust session, and you declined the free local preview", {
187
- next: "tot login (then re-run `tot start`), or `tot start --sample` for the free local preview",
183
+ throw new CliError("no Token of Trust session", {
184
+ next: "tot login (then re-run `tot start`)",
188
185
  });
189
186
  }
190
187
  return await runSampleStart(args, ctx, env, startedAt);
@@ -193,11 +190,17 @@ export async function run(argv, ctx) {
193
190
  // Authenticated path. Preflight (F) now that we know we're taking it — the
194
191
  // local machine checks (node/git/Docker) that the authed loop needs.
195
192
  await preflight(ctx, env);
196
- console.log(` ✓ signed in as ${session.identity}${session.appDomain ? ` (${session.appDomain})` : ""}`);
193
+ console.log(` ✓ signed in as ${describeIdentity(session)} via ${mcpOrigin(baseUrl)}`);
197
194
 
198
195
  // 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);
196
+ // Keep the raw client_list so a tool ERROR (unauthenticated / not-entitled) is
197
+ // surfaced with who + where, not collapsed to an empty list (fb-...f45zg9).
198
+ const listResp = await client.callTool("client_list", {});
199
+ const stores = normalizeStores(listResp);
200
+ const tenant = await resolveTenant(stores, args, env, baseUrl, {
201
+ session,
202
+ listErr: storeListError(listResp),
203
+ });
201
204
 
202
205
  // 4. checkout → ./<tenant> (reuse an existing checkout on a re-run),
203
206
  // OVERLAPPED (C1) with prefetching the runner: the native artifact by
@@ -213,7 +216,7 @@ export async function run(argv, ctx) {
213
216
  const runtime = { useDocker: args.docker, runnerDir: null };
214
217
  await Promise.all([
215
218
  ensureCheckout(client, tenant, dir, env),
216
- prefetchRuntime(client, devArgs, env, runtime),
219
+ prefetchRuntime(client, devArgs, env, runtime, ctx),
217
220
  ]);
218
221
 
219
222
  // 5. dev — native by default (F3), Docker on --docker or the fallback
@@ -340,14 +343,24 @@ async function runSampleStart(args, ctx, env, startedAt) {
340
343
  * AuthUnavailableError (no creds) or a CliError (can't reach the MCP).
341
344
  */
342
345
  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 });
346
+ // establishSession attaches auth in the right order relative to the handshake:
347
+ // the developer bearer BEFORE initialize (so the server binds this identity at
348
+ // initialize time — otherwise the session is anonymous and client_list is empty),
349
+ // the operator credential_validate after it. We inject our own initialize so the
350
+ // unreachable-MCP case still surfaces the actionable CliError below.
351
+ return establishSession(client, {
352
+ env,
353
+ prefer: args.identity || undefined,
354
+ initialize: async () => {
355
+ try {
356
+ await client.initialize();
357
+ } catch (e) {
358
+ throw new CliError(`can't reach the Token of Trust MCP at ${client.mcpUrl} (${String(e?.message || e)})`, {
359
+ next: "check your network, then re-run — or point elsewhere with --mcp <url>",
360
+ });
361
+ }
362
+ },
363
+ });
351
364
  }
352
365
 
353
366
  /**
@@ -402,11 +415,16 @@ async function preflight(ctx, env) {
402
415
  * Prefetch the runner (C1) so it's overlapped with checkout instead of paid
403
416
  * for serially afterward: the native renderer artifact by default (F3 — no
404
417
  * Docker prerequisite), or the Docker registry pull credential on --docker.
405
- * On a native fetch failure, falls back to Docker automatically — the same
406
- * fallback `tot dev` uses (dev.mjs#runStandalone) flipping
407
- * `runtime.useDocker` so the caller's subsequent dev step picks it up.
418
+ *
419
+ * On a native (entitled-artifact) fetch failure the runtime stays NATIVE: it
420
+ * falls back to the PUBLIC npm runner (@tokenoftrust/storefront-runner) the
421
+ * SAME renderer bits the entitled artifact ships, just delivered over
422
+ * public npm instead of a signed URL — so `tot dev`/`tot start` never require
423
+ * Docker just because `dev_renderer_artifact` isn't configured on a deployment.
424
+ * Docker is the LAST resort, only when the public runner is also unreachable
425
+ * (offline) — flipping `runtime.useDocker` so the caller's dev step picks it up.
408
426
  */
409
- async function prefetchRuntime(client, devArgs, env, runtime) {
427
+ async function prefetchRuntime(client, devArgs, env, runtime, ctx) {
410
428
  if (runtime.useDocker) {
411
429
  await prefetchDockerLogin(client, devArgs, env);
412
430
  return;
@@ -415,9 +433,15 @@ async function prefetchRuntime(client, devArgs, env, runtime) {
415
433
  runtime.runnerDir = await ensureRendererArtifact(devArgs, { client });
416
434
  } catch (e) {
417
435
  if (!(e instanceof NativeArtifactUnavailableError)) throw e;
418
- console.log(` ~ native runtime unavailable (${e.message}) falling back to the Docker runner.`);
419
- runtime.useDocker = true;
420
- await prefetchDockerLogin(client, devArgs, env);
436
+ // Entitled artifact unavailable — stay native on the public runner (no Docker).
437
+ try {
438
+ console.log(` ~ entitled renderer unavailable (${e.message}) using the public runner (no Docker).`);
439
+ runtime.runnerDir = await ensureSampleRenderer(devArgs, ctx, { env });
440
+ } catch (e2) {
441
+ console.log(` ~ public runner unavailable (${e2?.message || e2}) — falling back to the Docker runner.`);
442
+ runtime.useDocker = true;
443
+ await prefetchDockerLogin(client, devArgs, env);
444
+ }
421
445
  }
422
446
  }
423
447
 
@@ -436,12 +460,58 @@ async function prefetchDockerLogin(client, devArgs, env) {
436
460
  if (!dockerAvailable()) await tryStartDocker();
437
461
  }
438
462
 
463
+ /** A human label for a resolved session — the email when we could read it from
464
+ * the OAuth token, else the operator app domain, else the identity kind. */
465
+ function describeIdentity(session) {
466
+ if (!session) return "an unknown identity";
467
+ if (session.email) return session.email;
468
+ if (session.identity === "operator") {
469
+ return session.appDomain ? `operator (${session.appDomain})` : "operator";
470
+ }
471
+ return "your developer identity";
472
+ }
473
+
474
+ /** The MCP host for user-facing messages (never the full /mcp URL). */
475
+ function mcpOrigin(baseUrl) {
476
+ try {
477
+ return new URL(baseUrl).host;
478
+ } catch {
479
+ return String(baseUrl);
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Build the CliError for an authenticated session that surfaced NO usable store,
485
+ * making the dead-end SELF-DIAGNOSING (fb-1783905718950-f45zg9): always report who
486
+ * we're signed in as and against which MCP, and distinguish an ERRORED client_list
487
+ * (surface the reason — likely an auth/entitlement problem) from a genuinely empty
488
+ * result (invite may still be propagating, or you need one). Points at `tot whoami`.
489
+ */
490
+ function noStoresError({ session, baseUrl, listErr }) {
491
+ const who = describeIdentity(session);
492
+ const origin = mcpOrigin(baseUrl);
493
+ if (listErr) {
494
+ return new CliError(
495
+ `signed in as ${who} via ${origin}, but listing your stores failed: ${listErr}`,
496
+ { next: "run `tot whoami` to check your session, or `tot login` again — then re-run `tot start`" },
497
+ );
498
+ }
499
+ return new CliError(
500
+ `signed in as ${who} via ${origin}, but you have no stores to build on yet`,
501
+ {
502
+ next:
503
+ "if you were just invited, it may still be propagating — try again in a minute; " +
504
+ "otherwise ask your Token of Trust contact for a store invite (see `tot whoami`)",
505
+ },
506
+ );
507
+ }
508
+
439
509
  /**
440
510
  * Resolve the tenant to work on from the (normalized) store list, args, and
441
511
  * the remembered last tenant (A4) — then remember whatever was decided so the
442
512
  * next bare `tot start` doesn't have to ask again.
443
513
  */
444
- async function resolveTenant(stores, args, env, baseUrl) {
514
+ async function resolveTenant(stores, args, env, baseUrl, { session = null, listErr = null } = {}) {
445
515
  const lastTenantPath = defaultLastTenantPath(env);
446
516
  const pick = pickTenant(stores, {
447
517
  explicit: args.tenant || null,
@@ -450,9 +520,7 @@ async function resolveTenant(stores, args, env, baseUrl) {
450
520
 
451
521
  let tenant;
452
522
  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
- });
523
+ throw noStoresError({ session, baseUrl, listErr });
456
524
  } else if (pick.kind === "explicit") {
457
525
  tenant = pick.tenant;
458
526
  console.log(` → your store: ${tenant} (--tenant)`);
@@ -7,9 +7,8 @@
7
7
  * 3. report back — reconcile result + compliance verdict + the preview URL — from the MCP.
8
8
  *
9
9
  * This is submit-for-PREVIEW, not ship-to-live (`change_accept` / a future `tot ship`
10
- * is the separate ship gate). Step 3 calls the MCP `preview_status` read-back
11
- * (~/.tot-mcp/handoffs/2026-07-09-tot-mcp-tot-submit-contract.md): given the commit
12
- * just pushed it returns { status, reconcile, compliance, previewUrl } and we poll it
10
+ * is the separate ship gate). Step 3 calls the MCP `preview_status` read-back:
11
+ * given the commit just pushed it returns { status, reconcile, compliance, previewUrl } and we poll it
13
12
  * while reconcile is pending. If that tool isn't present (older MCP) the command still
14
13
  * validates + pushes and reports "reconcile pending" — degrading visibly, never a crash.
15
14
  *
@@ -33,7 +32,7 @@
33
32
  import { execFileSync } from "node:child_process";
34
33
  import { setTimeout as delay } from "node:timers/promises";
35
34
  import { createMcpClient } from "../mcp.mjs";
36
- import { resolveSession, AuthUnavailableError } from "../auth.mjs";
35
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
37
36
  import { validateTenant, ERROR } from "../validate.mjs";
38
37
  import { openBrowser } from "../open.mjs";
39
38
  import { fail } from "../errors.mjs";
@@ -138,8 +137,9 @@ export async function run(argv, ctx) {
138
137
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
139
138
  const client = createMcpClient(baseUrl);
140
139
  try {
141
- await client.initialize();
142
- await resolveSession(client, { env, prefer: args.identity || undefined });
140
+ // Attach auth before the first server call (developer bearer pre-initialize,
141
+ // operator credential_validate post-initialize) see establishSession.
142
+ await establishSession(client, { env, prefer: args.identity || undefined });
143
143
  // Set the active tenant so preview_status reads the right scope (it keys on
144
144
  // the session's tenant + the commit — no tenant arg of its own).
145
145
  await client.callTool("client_switch", { tenant });
@@ -230,8 +230,8 @@ const CHECK_GLYPH = { pass: "✓", fail: "✗", warn: "⚠", skip: "~" };
230
230
 
231
231
  /**
232
232
  * Print one per-rule compliance check: id/label, required-ness, why it didn't
233
- * pass (detail), and a concrete fix suggestion (hint) when there is one. This is
234
- * the moat over a bare pass/fail — an agent (or a human) reading `tot submit`
233
+ * pass (detail), and a concrete fix suggestion (hint) when there is one. This
234
+ * beats a bare pass/fail — an agent (or a human) reading `tot submit`
235
235
  * output should know exactly what to change and why, with no dashboard round-trip.
236
236
  */
237
237
  function reportComplianceCheck(c) {