@tokenoftrust/cli 1.4.0-rc.1 → 1.4.0-rc.11

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.
@@ -138,7 +138,7 @@ export async function run(argv, ctx) {
138
138
  console.error(
139
139
  fail(
140
140
  "nothing to run — you're not inside a tenant checkout",
141
- "tot checkout <tenant> --clone <dir> (then `cd` in and re-run), pass --workspace <dir>, or try `tot dev --sample`",
141
+ "tot clone <tenant> (then `cd` in and re-run), pass --workspace <dir>, or try `tot dev --sample`",
142
142
  ),
143
143
  );
144
144
  return 2;
@@ -194,7 +194,7 @@ async function runNativePublic(workspace, args, ctx) {
194
194
  const cfg = readWorkspaceConfig(workspace);
195
195
  if (!cfg) {
196
196
  throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
197
- next: "tot checkout <tenant> --clone <dir> (produces a runnable checkout)",
197
+ next: "tot clone <tenant> (produces a runnable checkout)",
198
198
  exitCode: 2,
199
199
  });
200
200
  }
@@ -280,7 +280,7 @@ async function runNative(workspace, args, ctx) {
280
280
  const cfg = readWorkspaceConfig(workspace);
281
281
  if (!cfg) {
282
282
  throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
283
- next: "tot checkout <tenant> --clone <dir> (produces a runnable checkout)",
283
+ next: "tot clone <tenant> (produces a runnable checkout)",
284
284
  exitCode: 2,
285
285
  });
286
286
  }
@@ -700,6 +700,66 @@ export function pinnedPublicCacheDir(cacheRoot, version) {
700
700
  return existsSync(join(dir, ".tot-cache-complete")) ? dir : null;
701
701
  }
702
702
 
703
+ /**
704
+ * List the installed package dirs that could carry a platform-native binary,
705
+ * normalised so a scoped package reads as `@scope+name` (mirroring pnpm's virtual
706
+ * store naming) regardless of the on-disk layout. Prefers the pnpm virtual store
707
+ * (`node_modules/.pnpm/*`); falls back to a shallow `node_modules` scan (one level
708
+ * into `@scope/`) for an npm/flat install. Best-effort — returns [] on any error.
709
+ */
710
+ function collectNativePackageDirs(runnerDir) {
711
+ const pnpmDir = join(runnerDir, "node_modules", ".pnpm");
712
+ if (existsSync(pnpmDir)) return readdirSync(pnpmDir);
713
+ const nm = join(runnerDir, "node_modules");
714
+ if (!existsSync(nm)) return [];
715
+ const names = [];
716
+ for (const e of readdirSync(nm, { withFileTypes: true })) {
717
+ if (e.name.startsWith("@") && e.isDirectory()) {
718
+ for (const s of readdirSync(join(nm, e.name))) names.push(`${e.name}+${s}`);
719
+ } else {
720
+ names.push(e.name);
721
+ }
722
+ }
723
+ return names;
724
+ }
725
+
726
+ /**
727
+ * True unless the installed renderer is POISONED for this host's CPU arch — the
728
+ * "the dev server didn't come up" root cause. Platform-native binding packages
729
+ * are named `<family>-<os>-<cpu>[-<abi>]` (e.g. `@esbuild/darwin-arm64`,
730
+ * `lightningcss-linux-x64-gnu`, `@rollup/rollup-win32-x64-msvc`). npm's optional-
731
+ * deps bug (npm/cli#4828) can materialise a DIFFERENT arch's variant than the host
732
+ * needs (e.g. darwin-x64 on an arm64 Mac) — pnpm gets it right. We flag the tree
733
+ * as poisoned exactly when a family ships a binding for our OS but NOT our OS+CPU,
734
+ * which is the precise shape of the `Cannot find native binding` boot crash. A
735
+ * family with no variant for our OS at all is a cross-platform optional dep that's
736
+ * correctly absent, so it never trips the check. Never throws; on any probe error
737
+ * it returns true (trust the cache) so a health probe can't itself break `tot dev`.
738
+ *
739
+ * `platform`/`arch` are injectable so this is testable off the host's real arch.
740
+ */
741
+ export function rendererCacheHealthy(runnerDir, { platform = process.platform, arch = process.arch } = {}) {
742
+ try {
743
+ const re = /^(.*?)[-+](darwin|linux|win32|freebsd|android|openharmony)-([a-z0-9]+)/;
744
+ const families = new Map(); // family -> Set("<os>-<cpu>")
745
+ for (const name of collectNativePackageDirs(runnerDir)) {
746
+ const m = name.match(re);
747
+ if (!m) continue;
748
+ const [, family, os, cpu] = m;
749
+ if (!families.has(family)) families.set(family, new Set());
750
+ families.get(family).add(`${os}-${cpu}`);
751
+ }
752
+ const want = `${platform}-${arch}`;
753
+ for (const variants of families.values()) {
754
+ const hasOurOs = [...variants].some((v) => v.startsWith(`${platform}-`));
755
+ if (hasOurOs && !variants.has(want)) return false; // wrong-arch binary present, ours missing
756
+ }
757
+ return true;
758
+ } catch {
759
+ return true;
760
+ }
761
+ }
762
+
703
763
  /**
704
764
  * Delete every OTHER public-runner cache dir (`public-*` except `keepVersion`)
705
765
  * once we've resolved the version this CLI pins to — so a pre-alignment dir
@@ -814,8 +874,12 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
814
874
  // Trust-but-verify: a `public-<version>` dir SHOULD be a current runner,
815
875
  // but if it can't report its own version it predates the --version surface
816
876
  // (a corrupt/half-migrated cache) — force a fresh fetch rather than run a
817
- // runner we can't identify. When it DOES answer, reuse it (fully offline-safe).
818
- if (probeRunnerVersion(exact)) {
877
+ // runner we can't identify. It must ALSO carry native bindings for THIS
878
+ // host's arch (rendererCacheHealthy) — a cache poisoned with the wrong-arch
879
+ // bindings (npm/cli#4828) would otherwise be reused forever and crash astro
880
+ // at boot with the swallowed "dev server didn't come up". When both hold,
881
+ // reuse it (fully offline-safe).
882
+ if (probeRunnerVersion(exact) && rendererCacheHealthy(exact)) {
819
883
  console.error(
820
884
  `~ renderer: cached public runner ${wantVersion} (${declared ? "declared by this store" : "matches this CLI"})`,
821
885
  );
@@ -823,7 +887,11 @@ export async function ensureSampleRenderer(args, ctx, { env = process.env, cache
823
887
  prunePublicRunnerCache(cacheRoot, wantVersion);
824
888
  return exact;
825
889
  }
826
- console.error(`~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`);
890
+ console.error(
891
+ rendererCacheHealthy(exact)
892
+ ? `~ renderer: cached runner at ${exact} can't report a version — refetching (forced upgrade)`
893
+ : `~ renderer: cached runner at ${exact} is missing native bindings for ${process.platform}-${process.arch} — refetching`,
894
+ );
827
895
  prunePublicRunnerCache(cacheRoot, null); // drop ALL public-* — none is trustworthy
828
896
  }
829
897
  }
@@ -888,7 +956,15 @@ function sourceVersionKey(source) {
888
956
  export async function installRunnerTarball({ source, version, isUrl = true, strip = 0 }, { log = (m) => console.error(m) } = {}) {
889
957
  const runnerDir = join(RENDERER_CACHE_ROOT, version);
890
958
  const marker = join(runnerDir, ".tot-cache-complete");
891
- if (existsSync(marker)) return runnerDir; // already downloaded + installed
959
+ if (existsSync(marker)) {
960
+ // Reuse ONLY if the cached tree carries native bindings for this host's arch.
961
+ // A cache poisoned with the wrong-arch binaries (npm/cli#4828 — e.g. darwin-x64
962
+ // on an arm64 Mac) is otherwise trusted forever, and astro crashes at boot with
963
+ // `Cannot find native binding`, swallowed as "the dev server didn't come up".
964
+ // Unhealthy → fall through and rebuild from the source below.
965
+ if (rendererCacheHealthy(runnerDir)) return runnerDir; // already downloaded + installed
966
+ log(`~ store preview engine cache is missing native bindings for ${process.platform}-${process.arch} — rebuilding it…`);
967
+ }
892
968
 
893
969
  // First run only — set the expectation so the one-time cost doesn't read as a
894
970
  // hang: this downloads + installs the renderer once, then every later run of
@@ -939,6 +1015,21 @@ export async function installRunnerTarball({ source, version, isUrl = true, stri
939
1015
  // entry that a later `tot dev` would treat as ready.
940
1016
  rmSync(runnerDir, { recursive: true, force: true });
941
1017
  renameSync(stagingDir, runnerDir);
1018
+ // Fence a fresh install against npm/cli#4828: if the installer left the wrong
1019
+ // arch's native bindings (or none) for this host, DON'T stamp the completion
1020
+ // marker — an unmarked tree is never reused, so the next run reinstalls cleanly
1021
+ // instead of caching the poison and crashing astro at boot. Fail loud + actionable
1022
+ // rather than swallow it as "the dev server didn't come up".
1023
+ if (!rendererCacheHealthy(runnerDir)) {
1024
+ await emitObstacle("renderer-native-bindings-missing");
1025
+ throw new CliError(
1026
+ `the store preview engine installed but is missing its native components for ${process.platform}-${process.arch}`,
1027
+ {
1028
+ next: "install pnpm (`npm i -g pnpm`, or `corepack enable`) and re-run `tot start` — pnpm installs the platform-native bits npm can skip (npm/cli#4828)",
1029
+ exitCode: 2,
1030
+ },
1031
+ );
1032
+ }
942
1033
  writeFileSync(marker, new Date().toISOString());
943
1034
  } finally {
944
1035
  if (isUrl) rmSync(archivePath, { force: true });
@@ -1059,21 +1150,25 @@ function spawnAsyncResult(cmd, args, opts = {}) {
1059
1150
  export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult } = {}) {
1060
1151
  const fd = logPath ? openSync(logPath, "a") : null;
1061
1152
  const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
1062
- // npm FIRST: it ships with EVERY Node (including 25+, where corepack is no
1063
- // longer bundled), so it's the launcher with zero machine-specific setup —
1064
- // no global pnpm, no corepack shim dance. The runner tree is built to be
1065
- // npm-installable (build-runner.mjs rewrites `workspace:*` "*" and emits an
1066
- // npm `workspaces` field; verified end-to-end with both installers). pnpm and
1067
- // the corepack-pinned pnpm remain as fallbacks for hosts with a broken npm.
1153
+ // pnpm FIRST: the renderer tree is a bundle of native optional deps (rolldown,
1154
+ // esbuild, sharp, @rollup, lightningcss, @tailwindcss/oxide, @astrojs/compiler,
1155
+ // workerd), and npm's optional-deps bug (npm/cli#4828) routinely installs the
1156
+ // WRONG arch's binary or none poisoning the cache so astro crashes at boot with
1157
+ // `Cannot find native binding`. pnpm resolves per-platform optional bindings
1158
+ // correctly (proven end-to-end). ensureCorepackPnpm() ran just above, so on the
1159
+ // Node floor (22.12, which bundles corepack) pnpm is available; `corepack pnpm`
1160
+ // is the shim path if a bare `pnpm` isn't on PATH. npm stays LAST as the escape
1161
+ // hatch for hosts with neither pnpm nor corepack — where rendererCacheHealthy()
1162
+ // then fences a poisoned result rather than shipping a broken cache silently.
1068
1163
  // If a launcher isn't installed at all (ENOENT) we move on; a launcher that
1069
1164
  // RAN but whose install failed is the real error and stops the loop.
1070
1165
  // REQUIRES a runner >= 1.3.4-rc.2 — older runner tarballs still carry
1071
1166
  // `workspace:*` deps npm rejects (harmless here: pickRunnerVersion pins the
1072
1167
  // runner to this CLI's version, so this CLI never installs those).
1073
1168
  const attempts = [
1074
- { cmd: "npm", args: ["install", "--no-audit", "--no-fund"] },
1075
1169
  { cmd: "pnpm", args: installArgs },
1076
1170
  { cmd: "corepack", args: ["pnpm", ...installArgs] },
1171
+ { cmd: "npm", args: ["install", "--no-audit", "--no-fund"] },
1077
1172
  ];
1078
1173
  try {
1079
1174
  for (const { cmd, args } of attempts) {
@@ -1240,7 +1335,7 @@ export function buildContainerPlan(workspace, args, _ctx) {
1240
1335
  const cfg = readWorkspaceConfig(workspace);
1241
1336
  if (!cfg) {
1242
1337
  throw new CliError(`${workspace} isn't a tenant checkout (no readable .tot/config.json)`, {
1243
- next: "tot checkout <tenant> --clone <dir> (produces a runnable checkout)",
1338
+ next: "tot clone <tenant> (produces a runnable checkout)",
1244
1339
  exitCode: 2,
1245
1340
  });
1246
1341
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `tot doctor` — is this machine ready to run the loop?
3
3
  *
4
- * Standalone (unlike the in-monorepo doctor): checks the things `tot checkout`
4
+ * Standalone (unlike the in-monorepo doctor): checks the things `tot clone`
5
5
  * and `tot dev`/`tot start` actually need — Node, git, an MCP URL, whether an
6
6
  * auth identity is available, and Docker — plus reports the detected context
7
7
  * so a developer knows which mode `tot` will use here.
@@ -187,7 +187,8 @@ export async function run(argv, ctx) {
187
187
  }
188
188
  console.log(
189
189
  failed === 0
190
- ? "\n✔ Ready. Try: tot start (checks out your store and runs it), or tot checkout\n"
190
+ ? "\n✔ Ready. Try: tot start (checks out your store and runs it), or tot clone." +
191
+ "\n The loop: tot dev → tot preview → tot ship.\n"
191
192
  : args.fix
192
193
  ? `\n✖ ${failed} check(s) still failing — see the exact next command(s) above, then re-run \`tot doctor\`.\n`
193
194
  : `\n✖ ${failed} check(s) failed — try \`tot doctor --fix\`, or fix the above, then re-run \`tot doctor\`.\n`,
@@ -199,5 +200,5 @@ function describeContext(ctx) {
199
200
  if (ctx.mode === "monorepo") return `storefront monorepo (${ctx.repoRoot})`;
200
201
  if (ctx.mode === "checkout")
201
202
  return `tenant checkout${ctx.tenant ? ` for "${ctx.tenant}"` : ""} (${ctx.workspacePath})`;
202
- return "loose (not inside a checkout — `tot checkout <tenant> --clone <dir>` to get one)";
203
+ return "loose (not inside a checkout — `tot clone <tenant>` to get one)";
203
204
  }
@@ -26,7 +26,7 @@
26
26
  import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
27
27
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
28
28
  import { createMcpClient } from "../mcp.mjs";
29
- import { storeListError } from "./checkout.mjs";
29
+ import { storeListError, noStoresGuidance } from "./clone.mjs";
30
30
  import { offerSignIn } from "./login.mjs";
31
31
  import { recordServerPolicy } from "../update-check.mjs";
32
32
 
@@ -238,8 +238,13 @@ export async function run(argv, _ctx) {
238
238
  return 1;
239
239
  }
240
240
  if (rows.length === 0) {
241
- console.log("\nNo stores you can act on yet for this identity.");
242
- console.log("If you were just invited, it may still be propagating — try again in a minute.");
241
+ // Status-aware, like whoami/checkout (card c2): an UNLINKED identity is told to
242
+ // link, not given the "may still be propagating" copy that's only the genuine
243
+ // linked-but-zero-grants case.
244
+ const g = noStoresGuidance(resp);
245
+ const line = g.headline.charAt(0).toUpperCase() + g.headline.slice(1);
246
+ console.log(`\n${line}.`);
247
+ console.log(`Next: ${g.next}`);
243
248
  return 0;
244
249
  }
245
250
 
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `tot link` — link your signed-in Token of Trust identity to the ToT identity
3
+ * broker so your store scope can be resolved.
4
+ *
5
+ * Some developers sign in fine (a valid ToT OAuth session) but their identity
6
+ * isn't yet linked to the broker that maps a person → the tenants they can build
7
+ * on — so `tot start` / `tot clone` / `tot whoami` resolve ZERO stores. That's
8
+ * NOT a "you weren't invited" problem and NOT "the invite is still propagating";
9
+ * it's a one-time link step. This command drives it end-to-end from the terminal:
10
+ *
11
+ * identity_link_begin → the MCP returns an authUrl + a poll handle
12
+ * open the authUrl → you approve the link in the browser
13
+ * identity_link_poll(handle) → we poll until it's linked, then confirm scope
14
+ *
15
+ * It reuses the SAME auth ceremony as the rest of the CLI (establishSession — the
16
+ * cached `tot login` session, offering an inline sign-in when there's none and
17
+ * we're on a TTY), so a not-signed-in developer isn't dead-ended.
18
+ *
19
+ * Dependency-free (node built-ins via mcp.mjs / auth.mjs / open.mjs).
20
+ */
21
+ import { setTimeout as delay } from "node:timers/promises";
22
+ import { createMcpClient } from "../mcp.mjs";
23
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
24
+ import { offerSignIn } from "./login.mjs";
25
+ import { openBrowser } from "../open.mjs";
26
+ import { CliError, fail, formatError } from "../errors.mjs";
27
+ import { normalizeStores } from "./clone.mjs";
28
+
29
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
30
+
31
+ function parseArgs(argv) {
32
+ const a = { mcp: null, help: false };
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const t = argv[i];
35
+ if (t === "--mcp") a.mcp = argv[++i];
36
+ else if (t === "--help" || t === "-h") a.help = true;
37
+ }
38
+ return a;
39
+ }
40
+
41
+ const USAGE = `tot link — link your Token of Trust identity so your stores resolve
42
+
43
+ tot link open the browser, approve the link, confirm your scope
44
+ tot link --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
45
+
46
+ Run this when \`tot whoami\` / \`tot start\` say your identity isn't linked yet.`;
47
+
48
+ /**
49
+ * Tolerant extraction of the fields `identity_link_begin` returns. The link URL
50
+ * and poll handle field names can vary by server version, so probe the known
51
+ * candidates (the `pollHandle` name is fixed by the identity_link_poll contract).
52
+ * Pure + exported so it's unit-tested without any I/O.
53
+ * @param {unknown} res
54
+ * @returns {{ authUrl: string|null, pollHandle: string|null }}
55
+ */
56
+ export function linkBeginFields(res) {
57
+ const c = res && typeof res === "object" && !Array.isArray(res) ? res : {};
58
+ const authUrl =
59
+ c.authUrl ||
60
+ c.url ||
61
+ c.verificationUrl ||
62
+ c.verificationUriComplete ||
63
+ c.verification_uri_complete ||
64
+ null;
65
+ const pollHandle = c.pollHandle || c.handle || c.poll_handle || c.pollHandleId || null;
66
+ return {
67
+ authUrl: typeof authUrl === "string" && authUrl ? authUrl : null,
68
+ pollHandle: typeof pollHandle === "string" && pollHandle ? pollHandle : null,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Classify an `identity_link_poll` result into 'linked' | 'pending' | a raw error
74
+ * status. Tolerant of the shape (a boolean flag, or a status/state string) so a
75
+ * server-version drift doesn't strand the poll. Pure + exported for unit tests.
76
+ * @param {unknown} res
77
+ * @returns {"linked"|"pending"|string}
78
+ */
79
+ export function linkPollStatus(res) {
80
+ const c = res && typeof res === "object" && !Array.isArray(res) ? res : {};
81
+ if (c.linked === true || c.done === true || c.complete === true) return "linked";
82
+ const raw =
83
+ (typeof c.status === "string" && c.status) || (typeof c.state === "string" && c.state) || "";
84
+ const s = raw.toLowerCase();
85
+ if (!s) return "pending";
86
+ // The broker namespaces its poll status with a `link_` prefix (e.g. `link_pending`,
87
+ // `link_approved`, `link_denied`) — strip it before matching so a normal "still
88
+ // waiting" state isn't misread as an unknown/error status and doesn't abort the poll.
89
+ // (Regression: `link_pending` fell through to the error branch and killed `tot link`
90
+ // the instant the developer hadn't yet finished the browser step.)
91
+ const bare = s.replace(/^link[_-]/, "");
92
+ if (/^(linked|link|complete|completed|done|ok|success|succeeded|active|approved|granted)$/.test(bare))
93
+ return "linked";
94
+ if (/^(pending|waiting|in_?progress|processing|started|created|authorizing|polling)$/.test(bare))
95
+ return "pending";
96
+ return s; // a terminal error status (link_denied, link_expired, …) — surfaced to the caller
97
+ }
98
+
99
+ /** @param {string[]} argv @param {any} _ctx */
100
+ export async function run(argv, _ctx) {
101
+ const env = process.env;
102
+ const args = parseArgs(argv);
103
+ if (args.help) {
104
+ console.log(USAGE);
105
+ return 0;
106
+ }
107
+
108
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
109
+ const client = createMcpClient(baseUrl);
110
+
111
+ try {
112
+ // Same auth ordering as checkout/start — developer bearer BEFORE initialize —
113
+ // and the same inline sign-in offer so a not-signed-in dev isn't dead-ended.
114
+ try {
115
+ await establishSession(client, { env });
116
+ } catch (e) {
117
+ if (e instanceof AuthUnavailableError && e.reason === "missing") {
118
+ const signedIn = await offerSignIn(client.mcpUrl, env, {});
119
+ if (!signedIn) throw e;
120
+ await establishSession(client, { env });
121
+ } else {
122
+ throw e;
123
+ }
124
+ }
125
+ console.error(`~ signed in → ${client.mcpUrl}`);
126
+
127
+ console.error("~ identity_link_begin — asking Token of Trust to start the link");
128
+ let begin;
129
+ try {
130
+ begin = await client.callTool("identity_link_begin", {});
131
+ } catch (e) {
132
+ throw new CliError(`couldn't start the identity link: ${String(e?.message || e)}`, {
133
+ next: "your MCP may not support `tot link` yet — run `tot whoami` for the current guidance",
134
+ });
135
+ }
136
+ const { authUrl, pollHandle } = linkBeginFields(begin);
137
+ if (!pollHandle) {
138
+ throw new CliError("Token of Trust didn't return a link handle to poll", {
139
+ next: "re-run `tot link`, or run `tot whoami` for the current guidance",
140
+ });
141
+ }
142
+
143
+ if (authUrl) {
144
+ const opened = openBrowser(authUrl);
145
+ console.log(
146
+ opened
147
+ ? `\n+ opening your browser to finish linking:\n ${authUrl}`
148
+ : `\nOpen this URL to finish linking your identity:\n ${authUrl}`,
149
+ );
150
+ } else {
151
+ console.log("\n+ finishing the link — no approval step needed …");
152
+ }
153
+
154
+ const linked = await pollLink(client, pollHandle, {
155
+ log: (m) => console.error(m),
156
+ });
157
+ if (!linked) {
158
+ throw new CliError("the identity link didn't complete in time", {
159
+ next: "finish the approval in your browser, then re-run `tot link`",
160
+ });
161
+ }
162
+
163
+ console.log("\n+ your identity is linked.");
164
+ // Confirm scope now resolves — best-effort, so a transient list hiccup doesn't
165
+ // fail an otherwise-successful link.
166
+ try {
167
+ const list = await client.callTool("client_list", {});
168
+ const stores = normalizeStores(list);
169
+ if (stores.length) {
170
+ console.log(` stores you can build on: ${stores.map((s) => s.id).join(", ")}`);
171
+ console.log(" Next: `tot start` (or `tot clone <tenant>`).");
172
+ } else {
173
+ console.log(" Next: `tot start` — if it still shows no stores, ask your ToT contact for a store invite.");
174
+ }
175
+ } catch {
176
+ console.log(" Next: `tot start` to build your store.");
177
+ }
178
+ return 0;
179
+ } catch (e) {
180
+ if (e instanceof AuthUnavailableError || e instanceof CliError) {
181
+ console.error(formatError(e));
182
+ return e instanceof CliError ? (e.exitCode ?? 1) : 1;
183
+ }
184
+ console.error(fail(`link failed: ${String(e?.message || e)}`));
185
+ return 1;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Poll `identity_link_poll(pollHandle)` until the link is complete, or give up
191
+ * after `timeoutMs`. Returns true on 'linked', false on timeout; throws a CliError
192
+ * on a definite error status the server reports. Injectable clock/interval keep it
193
+ * unit-testable, but the default path is the live poll.
194
+ * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
195
+ * @param {string} pollHandle
196
+ * @param {{ timeoutMs?: number, intervalMs?: number, log?: (m:string)=>void }} [opts]
197
+ * @returns {Promise<boolean>}
198
+ */
199
+ export async function pollLink(client, pollHandle, { timeoutMs = 120000, intervalMs = 2500, log = () => {} } = {}) {
200
+ const deadline = Date.now() + timeoutMs;
201
+ let announced = false;
202
+ while (Date.now() < deadline) {
203
+ let res;
204
+ try {
205
+ res = await client.callTool("identity_link_poll", { pollHandle });
206
+ } catch (e) {
207
+ // A transient poll error isn't fatal — keep trying until the deadline.
208
+ await delay(intervalMs);
209
+ continue;
210
+ }
211
+ const status = linkPollStatus(res);
212
+ if (status === "linked") return true;
213
+ if (status !== "pending") {
214
+ throw new CliError(`Token of Trust reported the link couldn't complete (${status})`, {
215
+ next: "re-run `tot link`, or run `tot whoami` for the current guidance",
216
+ });
217
+ }
218
+ if (!announced) {
219
+ log("~ waiting for you to approve the link in your browser …");
220
+ announced = true;
221
+ }
222
+ await delay(intervalMs);
223
+ }
224
+ return false;
225
+ }
@@ -4,7 +4,7 @@
4
4
  * Runs the MCP OAuth 2.1 PKCE loopback (the same ceremony `claude mcp add` runs):
5
5
  * opens the browser to the MCP's authorize page, the developer signs in with their
6
6
  * ToT identity + approves, and the loopback catches the code and exchanges it for a
7
- * token cached at ~/.tot/credentials.json. Every later command (`tot checkout`,
7
+ * token cached at ~/.tot/credentials.json. Every later command (`tot clone`,
8
8
  * `tot submit`, …) then runs as that developer with NO re-auth.
9
9
  *
10
10
  * The MCP defaults to the same target `tot submit` talks to (env MCP_BASE_URL /
@@ -19,12 +19,13 @@
19
19
  *
20
20
  * Dependency-free (node built-ins via oauth.mjs).
21
21
  */
22
- import { loginFlow, deviceLoginFlow, redeemCodeFlow, NoOpenerError } from "../oauth.mjs";
22
+ import { loginFlow, deviceLoginFlow, rendezvousLoginFlow, NoOpenerError } from "../oauth.mjs";
23
23
  import { defaultCredentialsPath, readCredentials, writeCredentials } from "../token-store.mjs";
24
24
  import { openBrowser } from "../open.mjs";
25
25
  import { fail } from "../errors.mjs";
26
26
  import { cockpitRecoveryUrl, normalizeEmailHint, redactEmailForHint, emailFromJwt } from "../auth.mjs";
27
27
  import { isInteractive, promptYesNo } from "../prompt.mjs";
28
+ import { versionStamp } from "../mcp.mjs";
28
29
 
29
30
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
30
31
 
@@ -108,7 +109,7 @@ const USAGE = `tot login — sign in to Token of Trust
108
109
  link a later \`tot feedback\` report to your invite + session) — not meant to be
109
110
  typed by hand.
110
111
 
111
- After signing in, run \`tot whoami\` to confirm, then \`tot checkout\` / \`tot submit\`.`;
112
+ After signing in, run \`tot whoami\` to confirm, then \`tot clone\` / \`tot submit\`.`;
112
113
 
113
114
  /**
114
115
  * The core of `tot login`: run the OAuth ceremony (browser loopback, or the
@@ -188,23 +189,25 @@ export async function offerSignIn(mcpUrl, env = process.env, {
188
189
  }
189
190
 
190
191
  /**
191
- * The core of `tot login --code`: run the browserless redemption (POST the invite
192
- * token to the MCP, get a grant back) and cache it, reusing a previously-registered
193
- * client for THIS MCP so we don't re-register on every login.
192
+ * The core of `tot login --code`: run the browserless RENDEZVOUS sign-in (attach the
193
+ * terminal's PKCE challenge to the pasted rendezvous handle, surface the fingerprint,
194
+ * poll until the developer approves it in the cockpit) and cache the grant, reusing a
195
+ * previously-registered client for THIS MCP so we don't re-register on every login.
196
+ * `log` carries the fingerprint + "waiting for approval" lines to the terminal.
194
197
  * @returns {Promise<object>} the credentials written to disk.
195
198
  */
196
- export async function redeemAndCache(mcpUrl, code, env = process.env) {
199
+ export async function redeemAndCache(mcpUrl, code, env = process.env, { log = () => {} } = {}) {
197
200
  const path = defaultCredentialsPath(env);
198
201
  const prior = readCredentials(path);
199
202
  const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
200
- const creds = await redeemCodeFlow({ mcpUrl, clientId, code });
203
+ const creds = await rendezvousLoginFlow({ mcpUrl, clientId, code, log });
201
204
  const merged = mergeActivityBridge(prior, mcpUrl, creds);
202
205
  writeCredentials(path, merged);
203
206
  return merged;
204
207
  }
205
208
 
206
209
  /**
207
- * `loginFlow`/`deviceLoginFlow`/`redeemCodeFlow` all return the bare OAuth shape —
210
+ * `loginFlow`/`deviceLoginFlow`/`rendezvousLoginFlow` all return the bare OAuth shape —
208
211
  * none of them know about activityToken/activityUrl, a SEPARATE credential this
209
212
  * same file caches via cacheActivityBridge. A bare re-login (no --code) after a
210
213
  * prior --code sign-in would otherwise silently drop it on the next `writeCredentials`
@@ -253,17 +256,21 @@ export async function run(argv, _ctx) {
253
256
  return 0;
254
257
  }
255
258
 
259
+ // Show which CLI/runtime is actually running BEFORE anything else — the first thing
260
+ // a "why did sign-in behave oddly" investigation needs (e.g. a stale shadowing `tot`).
261
+ console.error(versionStamp());
262
+
256
263
  const mcpUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
257
264
 
258
265
  if (args.code) {
259
266
  console.error(`~ signing in to Token of Trust with your invite code (${mcpUrl})`);
260
267
  try {
261
- await redeemAndCache(mcpUrl, args.code, env);
268
+ await redeemAndCache(mcpUrl, args.code, env, { log: (m) => console.error(m) });
262
269
  cacheActivityBridge(env, args.activityToken, args.activityUrl);
263
270
  cacheTraceId(env, args.traceId);
264
271
  cacheEmailHint(env, args.emailHint);
265
272
  console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
266
- console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
273
+ console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
267
274
  return 0;
268
275
  } catch (e) {
269
276
  console.error(
@@ -281,7 +288,7 @@ export async function run(argv, _ctx) {
281
288
  await loginAndCache(mcpUrl, env, { log: (m) => console.error(m), device: args.device });
282
289
  cacheTraceId(env, args.traceId);
283
290
  console.log(`\n+ signed in. Session cached to ${defaultCredentialsPath(env)}.`);
284
- console.log(" Next: `tot whoami` to confirm, or `tot checkout` / `tot submit` to build.");
291
+ console.log(" Next: `tot whoami` to confirm, or `tot clone` / `tot submit` to build.");
285
292
  return 0;
286
293
  } catch (e) {
287
294
  console.error(