@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.20

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.
@@ -0,0 +1,682 @@
1
+ /**
2
+ * `tot clone` — clone the tenant store you're entitled to build on, with an
3
+ * authenticated remote already configured, ready for the local loop. Mirrors
4
+ * `git clone <repo> [dir]`: the tenant is the repo, the dir defaults to the
5
+ * tenant name. (Named `clone`, not `checkout`, so it means what `git clone`
6
+ * means — materialize a local working copy — and never collides with the
7
+ * unrelated `git checkout` = switch-refs verb.)
8
+ *
9
+ * tot clone list the stores you can build on
10
+ * tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
11
+ * tot clone <tenant> <dir> clone into <dir>
12
+ * tot clone <tenant> --remote-only just mint + print the remote, don't materialize
13
+ *
14
+ * This is the SAME MCP code path a developer gets when they switch to a tenant:
15
+ * sign-in → client_switch(tenant) → tenant_checkout, where the MCP derives your
16
+ * per-tenant Git user and mints a FRESH, single-active, repo-scoped push
17
+ * credential (a later clone for the same tenant rotates it). `tot` performs NO
18
+ * privileged forge work itself — the MCP owns that. ("checkout" survives only as
19
+ * the domain/lease term: the `tenant_checkout` MCP tool + the on-disk noun for a
20
+ * materialized store dir — never as a developer-facing verb.)
21
+ *
22
+ * Auth is the developer's own ToT identity (the cached `tot login` session,
23
+ * resolved via src/auth.mjs). When there's no session yet and we're on a TTY, we
24
+ * offer to sign in right here and retry — no "run tot login, then re-run".
25
+ *
26
+ * OPERATOR CROSS-TENANT PR CLONE (unit u6). `tot clone <tenant> --pr <N> [dir]`
27
+ * materializes a PARTICULAR pull request's head — including on a tenant the
28
+ * operator does NOT own/isn't a member of (e.g. inspecting someone else's PR
29
+ * #7). This does NOT go through `tenant_checkout` — that tool mints a PUSH
30
+ * credential for your own active tenant only, its `tag` param is deprecated +
31
+ * ignored (repo-name cutover, 8425), and it flatly refuses a foreign tenant
32
+ * (over-scoped). Instead it mints a SHORT-lived, READ-ONLY credential via
33
+ * `repo_read_credential({ tenant })` — the read counterpart to tenant_checkout,
34
+ * server-authorized by tot20's `decideContentRead` (deny-by-default: org-wide
35
+ * read authority, ownership of the target, or a per-tenant operator grant). The
36
+ * SAME call also covers your OWN tenant (its exact-tenant path needs no grant),
37
+ * so `--pr` always takes this path regardless of membership — there is no
38
+ * PR-ref parameter on tenant_checkout to make the own-tenant case worth
39
+ * special-casing. Once cloned, the PR's head is fetched by NUMBER via Gitea's
40
+ * standard `refs/pull/<N>/head` ref — no separate PR-metadata lookup is needed
41
+ * (and `repo_pr_read`/`repo_pr_list` are app-session-only tools today, per their
42
+ * `guardCommon`/`boundApp` gate in tot-mcp — unreachable from this CLI's single
43
+ * developer-OAuth auth plane for a genuine cross-tenant human operator, so this
44
+ * command does not depend on them). The CLI never reimplements or bypasses the
45
+ * server's authorization decision — only requests it and relays the result.
46
+ *
47
+ * Dependency-free (global fetch + `git` via child_process).
48
+ */
49
+ import { execFile } from "node:child_process";
50
+ import { promisify } from "node:util";
51
+ import { createMcpClient } from "../mcp.mjs";
52
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
53
+ import { offerSignIn } from "./login.mjs";
54
+ import { CliError, fail, formatError } from "../errors.mjs";
55
+ import { writeNvmrc } from "../sample.mjs";
56
+ import { emitObstacle } from "../obstacle.mjs";
57
+
58
+ const execFileP = promisify(execFile);
59
+
60
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
61
+
62
+ function parseArgs(argv) {
63
+ const a = {
64
+ tenant: null,
65
+ dir: null,
66
+ tag: "main",
67
+ pr: null,
68
+ remoteOnly: false,
69
+ mcp: null,
70
+ printRemote: false,
71
+ help: false,
72
+ };
73
+ for (let i = 0; i < argv.length; i++) {
74
+ const t = argv[i];
75
+ if (t === "--tag") a.tag = argv[++i];
76
+ else if (t === "--pr") a.pr = argv[++i];
77
+ else if (t === "--remote-only") a.remoteOnly = true;
78
+ else if (t === "--mcp") a.mcp = argv[++i];
79
+ else if (t === "--print-remote") a.printRemote = true;
80
+ else if (t === "--help" || t === "-h") a.help = true;
81
+ // Positional, git-clone style: `tot clone <tenant> [<dir>]`.
82
+ else if (!t.startsWith("--")) {
83
+ if (!a.tenant) a.tenant = t;
84
+ else if (!a.dir) a.dir = t;
85
+ }
86
+ }
87
+ return a;
88
+ }
89
+
90
+ /**
91
+ * Parse + validate the `--pr` value into a positive integer, or null when
92
+ * absent. Throws a CliError (never a raw NaN downstream) on garbage input.
93
+ * Pure. @param {unknown} raw @returns {number|null}
94
+ */
95
+ export function parsePrNumber(raw) {
96
+ if (raw == null) return null;
97
+ const n = Number(raw);
98
+ if (!Number.isInteger(n) || n < 1) {
99
+ throw new CliError(`invalid --pr value "${raw}" — must be a positive PR number`, {
100
+ next: "tot clone <tenant> --pr <N> [dir]",
101
+ });
102
+ }
103
+ return n;
104
+ }
105
+
106
+ const USAGE = `tot clone — clone a tenant store you can build on (mirrors \`git clone\`)
107
+
108
+ tot clone list the stores you can build on
109
+ tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
110
+ tot clone <tenant> <dir> clone into <dir>
111
+ tot clone <tenant> --pr <N> [dir] OPERATOR: clone PR #<N>'s head, READ-ONLY
112
+ (works even on a tenant you don't own/aren't a
113
+ member of, when authorized; dir defaults to
114
+ <tenant>-pr<N>)
115
+
116
+ Options:
117
+ --tag <tag> which repo (repo = "<tenant>-<tag>"). Default: main.
118
+ --pr <N> clone PR #<N>'s head instead of the default branch — a
119
+ READ-ONLY cross-tenant credential, not a push checkout.
120
+ --remote-only don't clone; just mint + print the clone URL for <tenant>.
121
+ --mcp <url> MCP base URL. Default: env MCP_BASE_URL / TOT_MCP_URL, else
122
+ ${DEFAULT_MCP_URL}.
123
+ --print-remote with --remote-only, also print the authenticated remote
124
+ (contains a live token!).`;
125
+
126
+ /** Redact known secrets from any string before it hits the terminal. */
127
+ function makeRedactor(env) {
128
+ const secrets = [env.TOT_API_KEY, env.TOT_SECRET_KEY].filter(Boolean);
129
+ return (s) => {
130
+ let o = String(s);
131
+ for (const x of secrets) o = o.split(x).join("***");
132
+ return o;
133
+ };
134
+ }
135
+
136
+ /**
137
+ * @param {string[]} argv - args AFTER `checkout`
138
+ * @param {import("../context.mjs").detectContext extends (...a:any)=>infer R ? R : any} ctx
139
+ */
140
+ export async function run(argv, ctx) {
141
+ const env = process.env;
142
+ const args = parseArgs(argv);
143
+ if (args.help) {
144
+ console.log(USAGE);
145
+ return 0;
146
+ }
147
+
148
+ // Infer the tenant from a standalone checkout when run without one.
149
+ if (!args.tenant && ctx.mode === "checkout" && ctx.tenant) {
150
+ args.tenant = ctx.tenant;
151
+ }
152
+
153
+ let pr;
154
+ try {
155
+ pr = parsePrNumber(args.pr);
156
+ } catch (e) {
157
+ console.error(formatError(e));
158
+ return e.exitCode ?? 1;
159
+ }
160
+
161
+ // git-clone semantics: unless you explicitly asked for --remote-only, cloning
162
+ // is the default, and the target dir defaults to the tenant name (like
163
+ // `git clone <url>` deriving the dir from the repo basename) — or, with
164
+ // --pr, to "<tenant>-pr<N>" so a PR clone never collides with a plain one.
165
+ const cloneDir = args.remoteOnly ? null : args.dir || (pr ? `${args.tenant}-pr${pr}` : args.tenant);
166
+
167
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
168
+ const redact = makeRedactor(env);
169
+ const client = createMcpClient(baseUrl);
170
+
171
+ try {
172
+ // Attach auth in the right order relative to the handshake (developer bearer
173
+ // BEFORE initialize) — see establishSession. When there's no session yet and
174
+ // we're on a TTY, offer to sign in inline and retry once, so a not-signed-in
175
+ // developer isn't dead-ended at "run tot login, then re-run".
176
+ try {
177
+ await establishSession(client, { env });
178
+ } catch (e) {
179
+ if (e instanceof AuthUnavailableError && e.reason === "missing") {
180
+ const signedIn = await offerSignIn(client.mcpUrl, env, {});
181
+ if (!signedIn) throw e; // declined/non-TTY → the crisp error below
182
+ await establishSession(client, { env }); // retry once, in-flow
183
+ } else {
184
+ throw e;
185
+ }
186
+ }
187
+ console.error(`~ signed in → ${client.mcpUrl}`);
188
+
189
+ // No tenant → list the stores this identity can build on and stop.
190
+ if (!args.tenant) {
191
+ if (pr) {
192
+ console.error(fail("`tot clone --pr` needs a tenant", "tot clone <tenant> --pr <N> [dir]"));
193
+ return 2;
194
+ }
195
+ const list = await client.callTool("client_list", {});
196
+ printClientList(list);
197
+ return 0;
198
+ }
199
+
200
+ // --pr ALWAYS takes the cross-tenant read-credential path, even for your own
201
+ // tenant — tenant_checkout has no PR-ref parameter to select, so there is no
202
+ // membership-based fork here (see the file header comment).
203
+ if (pr) {
204
+ console.error(
205
+ `~ repo_read_credential (tenant=${args.tenant}) — MCP mints a read-only credential`,
206
+ );
207
+ const res = await cloneCrossTenantPr(client, {
208
+ tenant: args.tenant,
209
+ pr,
210
+ cloneDir,
211
+ redact,
212
+ });
213
+ console.log(`\n+ ready (read-only). repo: ${res.publicUrl}`);
214
+
215
+ if (res.cloned) {
216
+ console.log(`+ cloned PR #${pr}. HEAD: ${res.head}`);
217
+ console.log(`\nYour local read-only clone of PR #${pr} is at ${res.dir}.`);
218
+ console.log(` (this credential can only pull, never push — for your own tenant's`);
219
+ console.log(` push checkout, run \`tot clone ${args.tenant}\` without --pr)`);
220
+ return 0;
221
+ }
222
+
223
+ console.log(`\nPublic clone URL (no credential): ${res.publicUrl}`);
224
+ console.log(`Drop --remote-only to clone PR #${pr} with the read-only remote configured.`);
225
+ if (args.printRemote) {
226
+ console.log(
227
+ `\nREAD-ONLY remote (contains a live token — handle carefully):\n${res.gitRemote}`,
228
+ );
229
+ }
230
+ return 0;
231
+ }
232
+
233
+ console.error(`~ client_switch → ${args.tenant}`);
234
+ console.error(
235
+ `~ tenant_checkout (tag=${args.tag}) — MCP mints a fresh repo-scoped credential`,
236
+ );
237
+ const res = await checkoutTenant(client, {
238
+ tenant: args.tenant,
239
+ tag: args.tag,
240
+ cloneDir,
241
+ redact,
242
+ });
243
+ console.log(`\n+ ready. repo: ${res.cloneUrl || res.publicUrl}`);
244
+
245
+ if (res.cloned) {
246
+ console.log(`+ cloned. HEAD: ${res.head}`);
247
+ console.log(`\nYour local working clone is at ${res.dir} with an authenticated remote.`);
248
+ console.log(` cd ${res.dir} && tot dev # run it locally with save→reload`);
249
+ console.log(` (the minted token lives in .git/config; a later clone rotates it)`);
250
+ return 0;
251
+ }
252
+
253
+ console.log(`\nPublic clone URL (no credential): ${res.publicUrl}`);
254
+ console.log(`Drop --remote-only to clone with the authenticated remote configured.`);
255
+ if (args.printRemote) {
256
+ console.log(
257
+ `\nAUTHENTICATED remote (contains a live token — handle carefully):\n${res.gitRemote}`,
258
+ );
259
+ }
260
+ return 0;
261
+ } catch (e) {
262
+ if (e instanceof AuthUnavailableError) {
263
+ console.error(formatError(e));
264
+ return 1;
265
+ }
266
+ if (e instanceof CliError) {
267
+ console.error(formatError(e));
268
+ return e.exitCode ?? 1;
269
+ }
270
+ console.error(fail(`checkout failed: ${redact(String(e?.message || e))}`));
271
+ return 1;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * The privileged checkout core, composable in-process (used by `tot clone`
277
+ * and by `tot start`): client_switch → tenant_checkout → optionally clone. The
278
+ * MCP mints a fresh, repo-scoped push credential each time. Assumes `client` is
279
+ * already initialized + has a validated session.
280
+ *
281
+ * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
282
+ * @param {{ tenant: string, tag?: string, cloneDir?: string|null, redact?: (s:string)=>string }} opts
283
+ * @returns {Promise<{ gitRemote: string, cloneUrl: string|null, publicUrl: string,
284
+ * cloned: boolean, dir: string|null, head: string|null }>}
285
+ */
286
+ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
287
+ await client.callTool("client_switch", { tenant });
288
+ // vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
289
+ // storefront version-control app BEFORE checkout, so tenant_checkout can resolve
290
+ // `appForCaller` for them instead of 403-ing "No version-control app is bound." The
291
+ // human's OWN session self-triggers it (no subject_token — the subject is the
292
+ // caller's already-verified identity); the MCP entitlement-gates + authors the bind.
293
+ // Best-effort + FAIL-OPEN: a missing tool / cold MCP / any fault must never block a
294
+ // checkout that would otherwise succeed, and if the developer is still unbound the
295
+ // tenant_checkout error below (via checkoutError) is the honest backstop. Binding is
296
+ // also ensured at the authority seams (native OAuth sign-in) + backfill; this line
297
+ // self-heals a legacy session that predates them.
298
+ try {
299
+ // Wire name is `identity_bind` (tot-mcp taxonomy: <subject>_<action>).
300
+ await client.callTool("identity_bind", {});
301
+ } catch {
302
+ /* fail-open — see above */
303
+ }
304
+ const checkout = await client.callTool("tenant_checkout", { tenant, tag });
305
+
306
+ // A non-checkout result (not provisioned / not entitled / failed) must surface
307
+ // the MCP's human message + a concrete next step, NEVER a raw JSON.stringify
308
+ // dump (James's 2026-07-19 first-experience failure).
309
+ const err = checkoutError(checkout);
310
+ if (err) {
311
+ throw new CliError(redact(err.message), { next: err.next });
312
+ }
313
+ const gitRemote = checkout.gitRemote;
314
+ const cloneUrl = checkout.cloneUrl ?? null;
315
+ const u = new URL(gitRemote);
316
+ const publicUrl = `${u.protocol}//${u.host}${u.pathname}`;
317
+
318
+ if (!cloneDir) {
319
+ return { gitRemote, cloneUrl, publicUrl, cloned: false, dir: null, head: null };
320
+ }
321
+ const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact);
322
+ return { gitRemote, cloneUrl, publicUrl, cloned: true, dir, head };
323
+ }
324
+
325
+ /**
326
+ * git clone the authenticated remote into `dir`. Throws CliError on failure.
327
+ * Runs git as a NON-BLOCKING child process (promisified execFile) so the clone
328
+ * doesn't stall the Node event loop — `tot start` runs this inside a
329
+ * `Promise.all([...])` alongside the renderer prefetch, and a synchronous clone
330
+ * would serialize what's meant to overlap.
331
+ */
332
+ async function cloneRepo(gitRemote, dir, redact) {
333
+ const git = async (cargs) => (await execFileP("git", cargs)).stdout.toString();
334
+ console.log(`+ git clone → ${dir}`);
335
+ try {
336
+ await git(["clone", gitRemote, dir]);
337
+ } catch (e) {
338
+ // Beacon the cockpit before we surface the error — covers this path for both
339
+ // `tot clone` and `tot start` (which clones through here). Awaited so the
340
+ // packet lands before the process prints + exits; swallowed either way.
341
+ await emitObstacle("clone-failed");
342
+ throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
343
+ next: `check the target dir is empty and you can reach the remote, then re-run`,
344
+ });
345
+ }
346
+ const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
347
+ writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
348
+ return { dir, head };
349
+ }
350
+
351
+ /**
352
+ * The cross-tenant PR-clone core (unit u6), composable in-process exactly like
353
+ * `checkoutTenant`: mint a READ-ONLY credential via `repo_read_credential`, then
354
+ * (optionally) clone it and materialize PR #<pr>'s head. This is the path `--pr`
355
+ * ALWAYS takes — own tenant or foreign — because tenant_checkout has no PR-ref
356
+ * parameter (see the file header). Assumes `client` is already initialized +
357
+ * has a validated session.
358
+ *
359
+ * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
360
+ * @param {{ tenant: string, pr: number, cloneDir?: string|null, redact?: (s:string)=>string }} opts
361
+ * @returns {Promise<{ gitRemote: string, publicUrl: string, cloned: boolean,
362
+ * dir: string|null, head: string|null }>}
363
+ */
364
+ export async function cloneCrossTenantPr(
365
+ client,
366
+ { tenant, pr, cloneDir = null, redact = (s) => s },
367
+ ) {
368
+ const minted = await client.callTool("repo_read_credential", { tenant });
369
+
370
+ // A non-mint result (unauthenticated / forbidden / invalid_input / error) must
371
+ // surface the MCP's OWN denial reason + a concrete next step — never a raw
372
+ // JSON dump, and never a CLI-side re-derivation of the authorization call
373
+ // (the server's `decideContentRead` is the sole authority here).
374
+ const err = readCredentialError(minted);
375
+ if (err) {
376
+ throw new CliError(redact(err.message), { next: err.next });
377
+ }
378
+
379
+ const repos = Array.isArray(minted.repos) ? minted.repos : [];
380
+ const target = repos.find((r) => r.repo === tenant || r.owner === tenant) ?? repos[0];
381
+ if (!target || !target.gitRemote) {
382
+ throw new CliError(
383
+ `repo_read_credential minted no usable credential for tenant "${tenant}"`,
384
+ { next: "confirm the tenant name, then re-run" },
385
+ );
386
+ }
387
+ const gitRemote = target.gitRemote;
388
+ const u = new URL(gitRemote);
389
+ const publicUrl = `${u.protocol}//${u.host}${u.pathname}`;
390
+
391
+ if (!cloneDir) {
392
+ return { gitRemote, publicUrl, cloned: false, dir: null, head: null };
393
+ }
394
+ const { dir, head } = await cloneRepoAtPrRef(gitRemote, cloneDir, pr, redact);
395
+ return { gitRemote, publicUrl, cloned: true, dir, head };
396
+ }
397
+
398
+ /**
399
+ * git clone the authenticated (READ-ONLY) remote into `dir`, then materialize
400
+ * PR #<pr>'s head by NUMBER — Gitea exposes every pull request at the standard
401
+ * `refs/pull/<N>/head` ref, so this needs no separate PR-metadata lookup (see
402
+ * the file header on why `repo_pr_read` isn't used here). Runs git as a
403
+ * NON-BLOCKING child process (promisified execFile), same as `cloneRepo`.
404
+ * `deps.git` is injectable for tests (an async `(cargs) => stdout` runner);
405
+ * defaults to the real `git` binary. Throws CliError on failure.
406
+ * @param {string} gitRemote
407
+ * @param {string} dir
408
+ * @param {number} pr
409
+ * @param {(s: string) => string} redact
410
+ * @param {{ git?: (cargs: string[]) => Promise<string> }} [deps]
411
+ */
412
+ export async function cloneRepoAtPrRef(gitRemote, dir, pr, redact, deps = {}) {
413
+ const git = deps.git || (async (cargs) => (await execFileP("git", cargs)).stdout.toString());
414
+ console.log(`+ git clone → ${dir}`);
415
+ try {
416
+ await git(["clone", gitRemote, dir]);
417
+ } catch (e) {
418
+ await emitObstacle("clone-failed");
419
+ throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
420
+ next: `check the target dir is empty and you can reach the remote, then re-run`,
421
+ });
422
+ }
423
+ const prRef = `refs/pull/${pr}/head`;
424
+ console.log(`+ git fetch ${prRef}`);
425
+ try {
426
+ await git(["-C", dir, "fetch", "origin", prRef]);
427
+ await git(["-C", dir, "checkout", "-B", `pr-${pr}`, "FETCH_HEAD"]);
428
+ } catch (e) {
429
+ throw new CliError(
430
+ `couldn't fetch PR #${pr}: ${redact(String(e.stderr || e.message || e))}`,
431
+ { next: `confirm PR #${pr} exists on this tenant — \`tot pr list --tenant <t>\`, then re-run` },
432
+ );
433
+ }
434
+ const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
435
+ writeNvmrc(dir);
436
+ return { dir, head };
437
+ }
438
+
439
+ /**
440
+ * Classify a `repo_read_credential` tool result: null when it's a genuine,
441
+ * usable mint (carries at least one repo with a gitRemote), otherwise a human
442
+ * { message, next } pair — mirrors `checkoutError`'s contract exactly (never a
443
+ * raw JSON dump; the MCP's own denial reason + a concrete next step). Pure +
444
+ * exported so it's unit-tested without any I/O.
445
+ * @param {unknown} minted
446
+ * @returns {{ message: string, next: string }|null}
447
+ */
448
+ export function readCredentialError(minted) {
449
+ const c = minted && typeof minted === "object" && !Array.isArray(minted) ? minted : null;
450
+ if (c && Array.isArray(c.repos) && c.repos.some((r) => r && r.gitRemote)) return null;
451
+ const msg =
452
+ (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
453
+ (c && typeof c.raw === "string" && c.raw.trim() ? c.raw.trim() : null) ||
454
+ null;
455
+ if (typeof msg === "string" && /not authorized/i.test(msg)) {
456
+ return {
457
+ message: msg,
458
+ next:
459
+ "a cross-tenant read needs org-wide read authority, ownership of the target, or a " +
460
+ "per-tenant operator grant — ask your Token of Trust contact for one, or confirm the tenant name",
461
+ };
462
+ }
463
+ if (
464
+ typeof msg === "string" &&
465
+ /version-control app is bound|registered version-control app/i.test(msg)
466
+ ) {
467
+ return {
468
+ message: "your account isn't onboarded to read this tenant's content yet",
469
+ next: "re-run `tot login` to refresh your access, then retry — if it persists, ask your " +
470
+ "Token of Trust contact to finish onboarding your account",
471
+ };
472
+ }
473
+ return {
474
+ message: msg || "the read credential couldn't be minted",
475
+ next: "confirm the tenant name and that you're authorized to read it, then re-run",
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Classify a `tenant_checkout` tool result: null when it's a genuine, usable
481
+ * checkout (carries a gitRemote), otherwise a human { message, next } pair so
482
+ * callers surface the MCP's own words + a concrete next step instead of dumping
483
+ * raw JSON (James's 2026-07-19 first-experience failure: a not-provisioned store
484
+ * printed JSON.stringify(checkout)). callTool unwraps the tool result to its
485
+ * structuredContent / parsed text, so a failure surfaces as an error-ish status
486
+ * ('forbidden' | 'invalid_input' | 'checkout_failed' | 'error'), a `message`, or
487
+ * simply a missing gitRemote. When the repo isn't provisioned yet we speak to the
488
+ * INVITED DEVELOPER ("your store isn't set up yet"), not the operator — dropping
489
+ * the `repo_provision` jargon the MCP aims at whoever provisions. Any other
490
+ * failure surfaces the MCP's own message. Pure + exported so it's unit-tested
491
+ * without any I/O.
492
+ * @param {unknown} checkout
493
+ * @returns {{ message: string, next: string }|null}
494
+ */
495
+ export function checkoutError(checkout) {
496
+ const c = checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null;
497
+ if (c && c.gitRemote) return null; // a usable checkout — never an error
498
+ const msg =
499
+ (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
500
+ (c && typeof c.raw === "string" && c.raw.trim() ? c.raw.trim() : null) ||
501
+ null;
502
+ // The store's repo isn't provisioned yet — it isn't set up for this developer.
503
+ if (typeof msg === "string" && /not provisioned/i.test(msg)) {
504
+ return {
505
+ message: "your store isn't set up on Token of Trust yet",
506
+ next: "ask your Token of Trust contact to finish setting up your store, then re-run",
507
+ };
508
+ }
509
+ // Entitled, but not yet BOUND to the version-control app (appForCaller resolves
510
+ // nothing for this identity) — the 08-18 vc-app-binding gap. Distinct from
511
+ // not-provisioned: the STORE is fine; the developer just isn't onboarded to act
512
+ // THROUGH the app yet. The checkout preflight tries to self-heal this; if it still
513
+ // surfaces, the binding seam/backfill hasn't reached this identity — say so plainly
514
+ // instead of the generic "confirm you're entitled" (they ARE entitled).
515
+ if (
516
+ typeof msg === "string" &&
517
+ /version-control app is bound|registered version-control app/i.test(msg)
518
+ ) {
519
+ return {
520
+ message:
521
+ "your account isn't onboarded to check out this store yet (no version-control app is bound to your session)",
522
+ next: "re-run `tot login` to refresh your access, then retry — if it persists, ask your Token of Trust contact to finish onboarding your account",
523
+ };
524
+ }
525
+ return {
526
+ message: msg || "the store checkout couldn't be completed",
527
+ next: "confirm you're entitled to this store — `tot clone` (lists your stores)",
528
+ };
529
+ }
530
+
531
+ /**
532
+ * Normalize the (shape-varying) `client_list` response into a plain, sorted
533
+ * list of the stores this identity can build on. Shared by `tot clone`'s
534
+ * listing and `tot start`'s auto-pick so both read the same fields.
535
+ * @param {unknown} list
536
+ * @returns {Array<{ id: string, name: string, raw: any }>}
537
+ */
538
+ export function normalizeStores(list) {
539
+ const rows = Array.isArray(list) ? list : list?.clients || list?.tenants || [];
540
+ if (!Array.isArray(rows)) return [];
541
+ return rows
542
+ .map((r) => ({
543
+ id: r?.tenant || r?.id || r?.clientId || r?.appDomain || null,
544
+ name: r?.displayName || r?.name || "",
545
+ raw: r,
546
+ }))
547
+ .filter((r) => r.id);
548
+ }
549
+
550
+ /**
551
+ * Detect whether a `client_list` tool result is an ERROR result rather than a
552
+ * genuinely empty-but-successful store list — so callers surface it (identity +
553
+ * origin + reason, per fb-1783905718950-f45zg9) instead of collapsing it to an
554
+ * empty list and dead-ending at "ask for an invite". callTool unwraps a tool
555
+ * result to its structuredContent / parsed text, so an error surfaces as a
556
+ * status other than ok/success, an `error`/`isError` field, or an unparseable
557
+ * `raw` text blob. A response that carries a resolvable store array (even empty)
558
+ * is always a success. Returns a short human reason, or null when it's not an
559
+ * error. Pure + exported so it's unit-tested without any I/O.
560
+ * @param {unknown} list
561
+ * @returns {string|null}
562
+ */
563
+ export function storeListError(list) {
564
+ if (list == null || typeof list !== "object" || Array.isArray(list)) return null;
565
+ // A resolvable store array present → it succeeded, never an error.
566
+ if (Array.isArray(list.clients) || Array.isArray(list.tenants)) return null;
567
+ const msg =
568
+ list.message ||
569
+ (typeof list.error === "string" ? list.error : list.error?.message) ||
570
+ null;
571
+ if (list.isError) return msg || "the store list request returned an error";
572
+ if (typeof list.status === "string" && !/^(ok|success)$/i.test(list.status)) {
573
+ return msg || `the store list request returned status "${list.status}"`;
574
+ }
575
+ if (list.error) return msg || "the store list request returned an error";
576
+ if (typeof list.raw === "string" && list.raw.trim()) return list.raw.trim();
577
+ return null;
578
+ }
579
+
580
+ /**
581
+ * Extract card c1's broker-identity remediation signal from a `client_list`
582
+ * result. When the identity resolved ZERO stores, the server MAY carry:
583
+ * - `brokerStatus`: 'unlinked' | 'unconfigured' | 'broker_error' — present only
584
+ * when the empty scope is a BROKER-IDENTITY problem (not an entitlement one);
585
+ * ABSENT for the genuine linked-but-zero-grants case.
586
+ * - `nextAction`: a status-specific human remediation string the server owns
587
+ * (single source of truth; e.g. an unlinked identity is told to finish
588
+ * linking, an operator sees a diagnostic, zero-grants sees NO_TENANTS_MESSAGE).
589
+ * Returns { brokerStatus, nextAction } with nulls when absent. Pure + exported so
590
+ * it's unit-tested without any I/O.
591
+ * @param {unknown} list
592
+ * @returns {{ brokerStatus: string|null, nextAction: string|null }}
593
+ */
594
+ export function brokerRemediation(list) {
595
+ const c = list && typeof list === "object" && !Array.isArray(list) ? list : null;
596
+ const brokerStatus = c && typeof c.brokerStatus === "string" ? c.brokerStatus : null;
597
+ const nextAction =
598
+ c && typeof c.nextAction === "string" && c.nextAction.trim() ? c.nextAction.trim() : null;
599
+ return { brokerStatus, nextAction };
600
+ }
601
+
602
+ /**
603
+ * Status-aware human guidance for an authenticated identity that resolved ZERO
604
+ * stores (card c2 — the fix for the misleading "may still be propagating / ask for
605
+ * a store invite" copy that dead-ended an UNLINKED identity). Driven by card c1's
606
+ * `brokerStatus`/`nextAction`:
607
+ * - `unlinked` → the identity isn't linked to the ToT broker yet, so no scope can
608
+ * resolve. This is NOT an entitlement problem — point at `tot link` (the
609
+ * terminal action), NOT "ask for a store invite".
610
+ * - `unconfigured` | `broker_error` → an operator/diagnostic condition; surface
611
+ * the server's own `nextAction`, never "ask for an invite".
612
+ * - no brokerStatus → the genuine linked-but-zero-grants case; keep the existing
613
+ * "invite may still be propagating / ask for one" wording (NO_TENANTS_MESSAGE),
614
+ * preferring the server's `nextAction` when present.
615
+ *
616
+ * Prefers the server's `nextAction` as the concrete `next` step (single source of
617
+ * truth) and falls back to sensible local copy when it's absent (older server).
618
+ * `linkHint` names the terminal action an unlinked identity is pointed at — the
619
+ * built-in `tot link`; a caller that didn't build `tot link` passes an MCP-client
620
+ * phrasing instead. Pure + exported so it's unit-tested without any I/O.
621
+ * @param {unknown} list
622
+ * @param {{ linkHint?: string }} [opts]
623
+ * @returns {{ brokerStatus: string|null, headline: string, next: string }}
624
+ */
625
+ export function noStoresGuidance(list, { linkHint = "tot link" } = {}) {
626
+ const { brokerStatus, nextAction } = brokerRemediation(list);
627
+
628
+ if (brokerStatus === "unlinked") {
629
+ // The actionable terminal step beats the server's identity_link_begin/poll
630
+ // MCP-tool wording for a CLI user, so lead with `tot link` (c2 optional-arm
631
+ // directive). Fall back to the server string only if there's no link action.
632
+ return {
633
+ brokerStatus,
634
+ headline: "your Token of Trust identity isn't linked yet, so no stores could be resolved",
635
+ next: linkHint
636
+ ? `run \`${linkHint}\` to finish linking your identity, then re-run`
637
+ : nextAction || "finish linking your identity (identity_link_begin), then re-run",
638
+ };
639
+ }
640
+ if (brokerStatus === "unconfigured" || brokerStatus === "broker_error") {
641
+ // Operator/diagnostic conditions — surface the server's own words; do NOT tell
642
+ // the developer to "ask for an invite" (it isn't an entitlement problem).
643
+ return {
644
+ brokerStatus,
645
+ headline: "no stores could be resolved — the Token of Trust identity broker had a problem",
646
+ next: nextAction || "run `tot whoami` for details, or contact your Token of Trust operator",
647
+ };
648
+ }
649
+ // No brokerStatus → the genuine linked-but-zero-grants case.
650
+ return {
651
+ brokerStatus: null,
652
+ headline: "you have no stores to build on yet",
653
+ next:
654
+ nextAction ||
655
+ "if you were just invited, it may still be propagating — try again in a minute; " +
656
+ "otherwise ask your Token of Trust contact for a store invite (see `tot whoami`)",
657
+ };
658
+ }
659
+
660
+ function printClientList(list) {
661
+ const err = storeListError(list);
662
+ const stores = normalizeStores(list);
663
+ if (err && stores.length === 0) {
664
+ console.log(`\nCouldn't list your stores: ${err}`);
665
+ console.log("Run `tot whoami` to check your session, or `tot login` again.");
666
+ return;
667
+ }
668
+ if (stores.length === 0) {
669
+ // Status-aware, human-readable guidance — NEVER a raw JSON dump (the old
670
+ // behaviour, James's 2026-07-19 failure mode).
671
+ const g = noStoresGuidance(list);
672
+ const line = g.headline.charAt(0).toUpperCase() + g.headline.slice(1);
673
+ console.log(`\n${line}.`);
674
+ console.log(`Next: ${g.next}`);
675
+ return;
676
+ }
677
+ console.log("\nStores you can build on:\n");
678
+ for (const s of stores) {
679
+ console.log(` ${s.id}${s.name ? ` — ${s.name}` : ""}`);
680
+ }
681
+ console.log(`\nNext: tot clone <tenant>`);
682
+ }