@tokenoftrust/cli 1.4.0 → 1.5.0

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 (54) hide show
  1. package/README.md +5 -0
  2. package/bin/tot.mjs +148 -57
  3. package/package.json +6 -1
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +4 -4
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +3 -3
  8. package/src/commands/accept.mjs +498 -59
  9. package/src/commands/app/dev.mjs +8 -4
  10. package/src/commands/app/index.mjs +3 -3
  11. package/src/commands/app/scaffold.mjs +1 -1
  12. package/src/commands/branches.mjs +297 -0
  13. package/src/commands/cleanup.mjs +264 -0
  14. package/src/commands/clone.mjs +307 -25
  15. package/src/commands/dev.mjs +440 -156
  16. package/src/commands/doctor.mjs +4 -4
  17. package/src/commands/git-credential.mjs +180 -0
  18. package/src/commands/go-live.mjs +9 -5
  19. package/src/commands/grants.mjs +7 -5
  20. package/src/commands/hotfix.mjs +428 -0
  21. package/src/commands/ideas.mjs +2 -2
  22. package/src/commands/link.mjs +2 -2
  23. package/src/commands/login.mjs +5 -6
  24. package/src/commands/pr.mjs +62 -25
  25. package/src/commands/preview-build.mjs +6 -6
  26. package/src/commands/preview-doctor.mjs +225 -0
  27. package/src/commands/preview-retry-evidence.mjs +156 -0
  28. package/src/commands/preview.mjs +19 -3
  29. package/src/commands/revert.mjs +322 -0
  30. package/src/commands/rollback.mjs +18 -16
  31. package/src/commands/ship.mjs +51 -14
  32. package/src/commands/start.mjs +101 -59
  33. package/src/commands/submit.mjs +1183 -169
  34. package/src/commands/sync.mjs +203 -0
  35. package/src/commands/validate.mjs +10 -4
  36. package/src/commands/whoami.mjs +1 -1
  37. package/src/dev-heartbeat.mjs +3 -2
  38. package/src/dev-logs.mjs +2 -2
  39. package/src/errors.mjs +11 -4
  40. package/src/git-credential.mjs +257 -0
  41. package/src/last-tenant.mjs +1 -1
  42. package/src/mcp.mjs +6 -1
  43. package/src/merge-doctor-report.mjs +208 -0
  44. package/src/no-gitea-links.test.mjs +55 -0
  45. package/src/oauth.mjs +18 -14
  46. package/src/obstacle-beacon.cjs +2 -2
  47. package/src/obstacle.mjs +1 -1
  48. package/src/plan.mjs +83 -15
  49. package/src/sample.mjs +4 -4
  50. package/src/validate.mjs +187 -15
  51. package/src/vendor/private-apps-devkit.mjs +3 -3
  52. package/src/viewer-session.mjs +118 -0
  53. package/template/private-app/README.md +12 -6
  54. package/src/commands/retire.mjs +0 -203
@@ -23,6 +23,27 @@
23
23
  * resolved via src/auth.mjs). When there's no session yet and we're on a TTY, we
24
24
  * offer to sign in right here and retry — no "run tot login, then re-run".
25
25
  *
26
+ * OPERATOR CROSS-TENANT PR CLONE. `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
+ *
26
47
  * Dependency-free (global fetch + `git` via child_process).
27
48
  */
28
49
  import { execFile } from "node:child_process";
@@ -33,16 +54,19 @@ import { offerSignIn } from "./login.mjs";
33
54
  import { CliError, fail, formatError } from "../errors.mjs";
34
55
  import { writeNvmrc } from "../sample.mjs";
35
56
  import { emitObstacle } from "../obstacle.mjs";
57
+ import { CREDENTIAL_HELPER, splitAuthedRemote, basicAuthExtraHeader } from "../git-credential.mjs";
36
58
 
37
59
  const execFileP = promisify(execFile);
38
60
 
39
61
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
40
62
 
41
63
  function parseArgs(argv) {
64
+ /** @type {{ tenant: string|null, dir: string|null, tag: string, pr: string|null, remoteOnly: boolean, mcp: string|null, printRemote: boolean, help: boolean }} */
42
65
  const a = {
43
66
  tenant: null,
44
67
  dir: null,
45
68
  tag: "main",
69
+ pr: null,
46
70
  remoteOnly: false,
47
71
  mcp: null,
48
72
  printRemote: false,
@@ -51,6 +75,7 @@ function parseArgs(argv) {
51
75
  for (let i = 0; i < argv.length; i++) {
52
76
  const t = argv[i];
53
77
  if (t === "--tag") a.tag = argv[++i];
78
+ else if (t === "--pr") a.pr = argv[++i];
54
79
  else if (t === "--remote-only") a.remoteOnly = true;
55
80
  else if (t === "--mcp") a.mcp = argv[++i];
56
81
  else if (t === "--print-remote") a.printRemote = true;
@@ -64,14 +89,36 @@ function parseArgs(argv) {
64
89
  return a;
65
90
  }
66
91
 
92
+ /**
93
+ * Parse + validate the `--pr` value into a positive integer, or null when
94
+ * absent. Throws a CliError (never a raw NaN downstream) on garbage input.
95
+ * Pure. @param {unknown} raw @returns {number|null}
96
+ */
97
+ export function parsePrNumber(raw) {
98
+ if (raw == null) return null;
99
+ const n = Number(raw);
100
+ if (!Number.isInteger(n) || n < 1) {
101
+ throw new CliError(`invalid --pr value "${raw}" — must be a positive PR number`, {
102
+ next: "tot clone <tenant> --pr <N> [dir]",
103
+ });
104
+ }
105
+ return n;
106
+ }
107
+
67
108
  const USAGE = `tot clone — clone a tenant store you can build on (mirrors \`git clone\`)
68
109
 
69
110
  tot clone list the stores you can build on
70
111
  tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
71
112
  tot clone <tenant> <dir> clone into <dir>
113
+ tot clone <tenant> --pr <N> [dir] OPERATOR: clone PR #<N>'s head, READ-ONLY
114
+ (works even on a tenant you don't own/aren't a
115
+ member of, when authorized; dir defaults to
116
+ <tenant>-pr<N>)
72
117
 
73
118
  Options:
74
119
  --tag <tag> which repo (repo = "<tenant>-<tag>"). Default: main.
120
+ --pr <N> clone PR #<N>'s head instead of the default branch — a
121
+ READ-ONLY cross-tenant credential, not a push checkout.
75
122
  --remote-only don't clone; just mint + print the clone URL for <tenant>.
76
123
  --mcp <url> MCP base URL. Default: env MCP_BASE_URL / TOT_MCP_URL, else
77
124
  ${DEFAULT_MCP_URL}.
@@ -105,10 +152,19 @@ export async function run(argv, ctx) {
105
152
  args.tenant = ctx.tenant;
106
153
  }
107
154
 
155
+ let pr;
156
+ try {
157
+ pr = parsePrNumber(args.pr);
158
+ } catch (e) {
159
+ console.error(formatError(e));
160
+ return e.exitCode ?? 1;
161
+ }
162
+
108
163
  // git-clone semantics: unless you explicitly asked for --remote-only, cloning
109
164
  // is the default, and the target dir defaults to the tenant name (like
110
- // `git clone <url>` deriving the dir from the repo basename).
111
- const cloneDir = args.remoteOnly ? null : args.dir || args.tenant;
165
+ // `git clone <url>` deriving the dir from the repo basename) — or, with
166
+ // --pr, to "<tenant>-pr<N>" so a PR clone never collides with a plain one.
167
+ const cloneDir = args.remoteOnly ? null : args.dir || (pr ? `${args.tenant}-pr${pr}` : args.tenant);
112
168
 
113
169
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
114
170
  const redact = makeRedactor(env);
@@ -134,11 +190,48 @@ export async function run(argv, ctx) {
134
190
 
135
191
  // No tenant → list the stores this identity can build on and stop.
136
192
  if (!args.tenant) {
193
+ if (pr) {
194
+ console.error(fail("`tot clone --pr` needs a tenant", "tot clone <tenant> --pr <N> [dir]"));
195
+ return 2;
196
+ }
137
197
  const list = await client.callTool("client_list", {});
138
198
  printClientList(list);
139
199
  return 0;
140
200
  }
141
201
 
202
+ // --pr ALWAYS takes the cross-tenant read-credential path, even for your own
203
+ // tenant — tenant_checkout has no PR-ref parameter to select, so there is no
204
+ // membership-based fork here (see the file header comment).
205
+ if (pr) {
206
+ console.error(
207
+ `~ repo_read_credential (tenant=${args.tenant}) — MCP mints a read-only credential`,
208
+ );
209
+ const res = await cloneCrossTenantPr(client, {
210
+ tenant: args.tenant,
211
+ pr,
212
+ cloneDir,
213
+ redact,
214
+ });
215
+ console.log(`\n+ ready (read-only). repo: ${res.publicUrl}`);
216
+
217
+ if (res.cloned) {
218
+ console.log(`+ cloned PR #${pr}. HEAD: ${res.head}`);
219
+ console.log(`\nYour local read-only clone of PR #${pr} is at ${res.dir}.`);
220
+ console.log(` (this credential can only pull, never push — for your own tenant's`);
221
+ console.log(` push checkout, run \`tot clone ${args.tenant}\` without --pr)`);
222
+ return 0;
223
+ }
224
+
225
+ console.log(`\nPublic clone URL (no credential): ${res.publicUrl}`);
226
+ console.log(`Drop --remote-only to clone PR #${pr} with the read-only remote configured.`);
227
+ if (args.printRemote) {
228
+ console.log(
229
+ `\nREAD-ONLY remote (contains a live token — handle carefully):\n${res.gitRemote}`,
230
+ );
231
+ }
232
+ return 0;
233
+ }
234
+
142
235
  console.error(`~ client_switch → ${args.tenant}`);
143
236
  console.error(
144
237
  `~ tenant_checkout (tag=${args.tag}) — MCP mints a fresh repo-scoped credential`,
@@ -153,9 +246,9 @@ export async function run(argv, ctx) {
153
246
 
154
247
  if (res.cloned) {
155
248
  console.log(`+ cloned. HEAD: ${res.head}`);
156
- console.log(`\nYour local working clone is at ${res.dir} with an authenticated remote.`);
249
+ console.log(`\nYour local working clone is at ${res.dir}.`);
157
250
  console.log(` cd ${res.dir} && tot dev # run it locally with save→reload`);
158
- console.log(` (the minted token lives in .git/config; a later clone rotates it)`);
251
+ console.log(` (origin has no token \`tot\` mints one fresh at fetch/push time)`);
159
252
  return 0;
160
253
  }
161
254
 
@@ -194,6 +287,22 @@ export async function run(argv, ctx) {
194
287
  */
195
288
  export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
196
289
  await client.callTool("client_switch", { tenant });
290
+ // vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
291
+ // storefront version-control app BEFORE checkout, so tenant_checkout can resolve
292
+ // `appForCaller` for them instead of 403-ing "No version-control app is bound." The
293
+ // human's OWN session self-triggers it (no subject_token — the subject is the
294
+ // caller's already-verified identity); the MCP entitlement-gates + authors the bind.
295
+ // Best-effort + FAIL-OPEN: a missing tool / cold MCP / any fault must never block a
296
+ // checkout that would otherwise succeed, and if the developer is still unbound the
297
+ // tenant_checkout error below (via checkoutError) is the honest backstop. Binding is
298
+ // also ensured at the authority seams (native OAuth sign-in) + backfill; this line
299
+ // self-heals a legacy session that predates them.
300
+ try {
301
+ // Wire name is `identity_bind` (tot-mcp taxonomy: <subject>_<action>).
302
+ await client.callTool("identity_bind", {});
303
+ } catch {
304
+ /* fail-open — see above */
305
+ }
197
306
  const checkout = await client.callTool("tenant_checkout", { tenant, tag });
198
307
 
199
308
  // A non-checkout result (not provisioned / not entitled / failed) must surface
@@ -216,17 +325,38 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
216
325
  }
217
326
 
218
327
  /**
219
- * git clone the authenticated remote into `dir`. Throws CliError on failure.
220
- * Runs git as a NON-BLOCKING child process (promisified execFile) so the clone
221
- * doesn't stall the Node event loop `tot start` runs this inside a
222
- * `Promise.all([...])` alongside the renderer prefetch, and a synchronous clone
223
- * would serialize what's meant to overlap.
328
+ * git clone the authenticated remote into `dir` WITHOUT ever writing the
329
+ * live token to `.git/config`. The MCP mints `gitRemote` as a
330
+ * basic-auth URL (`user:token@host`); rather than passing that straight to
331
+ * `git clone` (which records exactly the URL it was given as `origin`, token
332
+ * and all), the token is handed to git
333
+ * EPHEMERALLY via a one-shot `http.extraheader` (same mechanism
334
+ * pushPreviewRef in submit.mjs uses for a push) while the clone SOURCE is
335
+ * already the tokenless public URL — so `origin` comes out tokenless from
336
+ * the very first commit. The new checkout's `credential.helper` is then
337
+ * configured to `tot git-credential`, so every later fetch/push mints a
338
+ * fresh token through the CLI's own login session instead of ever needing
339
+ * one persisted on disk (a later clone no longer needs to "rotate" anything
340
+ * — the old flow's `.git/config` token is simply never written).
341
+ *
342
+ * Runs git as a NON-BLOCKING child process (promisified execFile) so the
343
+ * clone doesn't stall the Node event loop — `tot start` runs this inside a
344
+ * `Promise.all([...])` alongside the renderer prefetch, and a synchronous
345
+ * clone would serialize what's meant to overlap. Throws CliError on failure.
224
346
  */
225
347
  async function cloneRepo(gitRemote, dir, redact) {
226
348
  const git = async (cargs) => (await execFileP("git", cargs)).stdout.toString();
227
349
  console.log(`+ git clone → ${dir}`);
350
+ const cred = splitAuthedRemote(gitRemote);
351
+ const cloneArgs = cred
352
+ ? [
353
+ "-c", `http.extraheader=${basicAuthExtraHeader(cred.username, cred.token)}`,
354
+ "-c", "credential.helper=",
355
+ "clone", cred.publicUrl, dir,
356
+ ]
357
+ : ["clone", gitRemote, dir]; // no embedded token (e.g. already public) — clone as given
228
358
  try {
229
- await git(["clone", gitRemote, dir]);
359
+ await git(cloneArgs);
230
360
  } catch (e) {
231
361
  // Beacon the cockpit before we surface the error — covers this path for both
232
362
  // `tot clone` and `tot start` (which clones through here). Awaited so the
@@ -236,11 +366,145 @@ async function cloneRepo(gitRemote, dir, redact) {
236
366
  next: `check the target dir is empty and you can reach the remote, then re-run`,
237
367
  });
238
368
  }
369
+ try {
370
+ await git(["-C", dir, "config", "--local", "credential.helper", CREDENTIAL_HELPER]);
371
+ } catch {
372
+ // Best-effort — a checkout missing the helper still WORKS (the token just
373
+ // isn't self-healing yet); the next `tot preview`/`tot sync` migrates it.
374
+ }
239
375
  const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
240
376
  writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
241
377
  return { dir, head };
242
378
  }
243
379
 
380
+ /**
381
+ * The cross-tenant PR-clone core, composable in-process exactly like
382
+ * `checkoutTenant`: mint a READ-ONLY credential via `repo_read_credential`, then
383
+ * (optionally) clone it and materialize PR #<pr>'s head. This is the path `--pr`
384
+ * ALWAYS takes — own tenant or foreign — because tenant_checkout has no PR-ref
385
+ * parameter (see the file header). Assumes `client` is already initialized +
386
+ * has a validated session.
387
+ *
388
+ * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
389
+ * @param {{ tenant: string, pr: number, cloneDir?: string|null, redact?: (s:string)=>string }} opts
390
+ * @returns {Promise<{ gitRemote: string, publicUrl: string, cloned: boolean,
391
+ * dir: string|null, head: string|null }>}
392
+ */
393
+ export async function cloneCrossTenantPr(
394
+ client,
395
+ { tenant, pr, cloneDir = null, redact = (s) => s },
396
+ ) {
397
+ const minted = await client.callTool("repo_read_credential", { tenant });
398
+
399
+ // A non-mint result (unauthenticated / forbidden / invalid_input / error) must
400
+ // surface the MCP's OWN denial reason + a concrete next step — never a raw
401
+ // JSON dump, and never a CLI-side re-derivation of the authorization call
402
+ // (the server's `decideContentRead` is the sole authority here).
403
+ const err = readCredentialError(minted);
404
+ if (err) {
405
+ throw new CliError(redact(err.message), { next: err.next });
406
+ }
407
+
408
+ const repos = Array.isArray(minted.repos) ? minted.repos : [];
409
+ const target = repos.find((r) => r.repo === tenant || r.owner === tenant) ?? repos[0];
410
+ if (!target || !target.gitRemote) {
411
+ throw new CliError(
412
+ `repo_read_credential minted no usable credential for tenant "${tenant}"`,
413
+ { next: "confirm the tenant name, then re-run" },
414
+ );
415
+ }
416
+ const gitRemote = target.gitRemote;
417
+ const u = new URL(gitRemote);
418
+ const publicUrl = `${u.protocol}//${u.host}${u.pathname}`;
419
+
420
+ if (!cloneDir) {
421
+ return { gitRemote, publicUrl, cloned: false, dir: null, head: null };
422
+ }
423
+ const { dir, head } = await cloneRepoAtPrRef(gitRemote, cloneDir, pr, redact);
424
+ return { gitRemote, publicUrl, cloned: true, dir, head };
425
+ }
426
+
427
+ /**
428
+ * git clone the authenticated (READ-ONLY) remote into `dir`, then materialize
429
+ * PR #<pr>'s head by NUMBER — Gitea exposes every pull request at the standard
430
+ * `refs/pull/<N>/head` ref, so this needs no separate PR-metadata lookup (see
431
+ * the file header on why `repo_pr_read` isn't used here). Runs git as a
432
+ * NON-BLOCKING child process (promisified execFile), same as `cloneRepo`.
433
+ * `deps.git` is injectable for tests (an async `(cargs) => stdout` runner);
434
+ * defaults to the real `git` binary. Throws CliError on failure.
435
+ * @param {string} gitRemote
436
+ * @param {string} dir
437
+ * @param {number} pr
438
+ * @param {(s: string) => string} redact
439
+ * @param {{ git?: (cargs: string[]) => Promise<string> }} [deps]
440
+ */
441
+ export async function cloneRepoAtPrRef(gitRemote, dir, pr, redact, deps = {}) {
442
+ const git = deps.git || (async (cargs) => (await execFileP("git", cargs)).stdout.toString());
443
+ console.log(`+ git clone → ${dir}`);
444
+ try {
445
+ await git(["clone", gitRemote, dir]);
446
+ } catch (e) {
447
+ await emitObstacle("clone-failed");
448
+ throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
449
+ next: `check the target dir is empty and you can reach the remote, then re-run`,
450
+ });
451
+ }
452
+ const prRef = `refs/pull/${pr}/head`;
453
+ console.log(`+ git fetch ${prRef}`);
454
+ try {
455
+ await git(["-C", dir, "fetch", "origin", prRef]);
456
+ await git(["-C", dir, "checkout", "-B", `pr-${pr}`, "FETCH_HEAD"]);
457
+ } catch (e) {
458
+ throw new CliError(
459
+ `couldn't fetch PR #${pr}: ${redact(String(e.stderr || e.message || e))}`,
460
+ { next: `confirm PR #${pr} exists on this tenant — \`tot pr list --tenant <t>\`, then re-run` },
461
+ );
462
+ }
463
+ const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
464
+ writeNvmrc(dir);
465
+ return { dir, head };
466
+ }
467
+
468
+ /**
469
+ * Classify a `repo_read_credential` tool result: null when it's a genuine,
470
+ * usable mint (carries at least one repo with a gitRemote), otherwise a human
471
+ * { message, next } pair — mirrors `checkoutError`'s contract exactly (never a
472
+ * raw JSON dump; the MCP's own denial reason + a concrete next step). Pure +
473
+ * exported so it's unit-tested without any I/O.
474
+ * @param {unknown} minted
475
+ * @returns {{ message: string, next: string }|null}
476
+ */
477
+ export function readCredentialError(minted) {
478
+ const c = /** @type {any} */ (minted && typeof minted === "object" && !Array.isArray(minted) ? minted : null);
479
+ if (c && Array.isArray(c.repos) && c.repos.some((r) => r && r.gitRemote)) return null;
480
+ const msg =
481
+ (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
482
+ (c && typeof c.raw === "string" && c.raw.trim() ? c.raw.trim() : null) ||
483
+ null;
484
+ if (typeof msg === "string" && /not authorized/i.test(msg)) {
485
+ return {
486
+ message: msg,
487
+ next:
488
+ "a cross-tenant read needs org-wide read authority, ownership of the target, or a " +
489
+ "per-tenant operator grant — ask your Token of Trust contact for one, or confirm the tenant name",
490
+ };
491
+ }
492
+ if (
493
+ typeof msg === "string" &&
494
+ /version-control app is bound|registered version-control app/i.test(msg)
495
+ ) {
496
+ return {
497
+ message: "your account isn't onboarded to read this tenant's content yet",
498
+ next: "re-run `tot login` to refresh your access, then retry — if it persists, ask your " +
499
+ "Token of Trust contact to finish onboarding your account",
500
+ };
501
+ }
502
+ return {
503
+ message: msg || "the read credential couldn't be minted",
504
+ next: "confirm the tenant name and that you're authorized to read it, then re-run",
505
+ };
506
+ }
507
+
244
508
  /**
245
509
  * Classify a `tenant_checkout` tool result: null when it's a genuine, usable
246
510
  * checkout (carries a gitRemote), otherwise a human { message, next } pair so
@@ -258,7 +522,7 @@ async function cloneRepo(gitRemote, dir, redact) {
258
522
  * @returns {{ message: string, next: string }|null}
259
523
  */
260
524
  export function checkoutError(checkout) {
261
- const c = checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null;
525
+ const c = /** @type {any} */ (checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null);
262
526
  if (c && c.gitRemote) return null; // a usable checkout — never an error
263
527
  const msg =
264
528
  (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
@@ -271,6 +535,22 @@ export function checkoutError(checkout) {
271
535
  next: "ask your Token of Trust contact to finish setting up your store, then re-run",
272
536
  };
273
537
  }
538
+ // Entitled, but not yet BOUND to the version-control app (appForCaller resolves
539
+ // nothing for this identity) — the 08-18 vc-app-binding gap. Distinct from
540
+ // not-provisioned: the STORE is fine; the developer just isn't onboarded to act
541
+ // THROUGH the app yet. The checkout preflight tries to self-heal this; if it still
542
+ // surfaces, the binding seam/backfill hasn't reached this identity — say so plainly
543
+ // instead of the generic "confirm you're entitled" (they ARE entitled).
544
+ if (
545
+ typeof msg === "string" &&
546
+ /version-control app is bound|registered version-control app/i.test(msg)
547
+ ) {
548
+ return {
549
+ message:
550
+ "your account isn't onboarded to check out this store yet (no version-control app is bound to your session)",
551
+ 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",
552
+ };
553
+ }
274
554
  return {
275
555
  message: msg || "the store checkout couldn't be completed",
276
556
  next: "confirm you're entitled to this store — `tot clone` (lists your stores)",
@@ -285,7 +565,8 @@ export function checkoutError(checkout) {
285
565
  * @returns {Array<{ id: string, name: string, raw: any }>}
286
566
  */
287
567
  export function normalizeStores(list) {
288
- const rows = Array.isArray(list) ? list : list?.clients || list?.tenants || [];
568
+ const l = /** @type {any} */ (list);
569
+ const rows = Array.isArray(l) ? l : l?.clients || l?.tenants || [];
289
570
  if (!Array.isArray(rows)) return [];
290
571
  return rows
291
572
  .map((r) => ({
@@ -311,23 +592,24 @@ export function normalizeStores(list) {
311
592
  */
312
593
  export function storeListError(list) {
313
594
  if (list == null || typeof list !== "object" || Array.isArray(list)) return null;
595
+ const l = /** @type {any} */ (list);
314
596
  // A resolvable store array present → it succeeded, never an error.
315
- if (Array.isArray(list.clients) || Array.isArray(list.tenants)) return null;
597
+ if (Array.isArray(l.clients) || Array.isArray(l.tenants)) return null;
316
598
  const msg =
317
- list.message ||
318
- (typeof list.error === "string" ? list.error : list.error?.message) ||
599
+ l.message ||
600
+ (typeof l.error === "string" ? l.error : l.error?.message) ||
319
601
  null;
320
- if (list.isError) return msg || "the store list request returned an error";
321
- if (typeof list.status === "string" && !/^(ok|success)$/i.test(list.status)) {
322
- return msg || `the store list request returned status "${list.status}"`;
602
+ if (l.isError) return msg || "the store list request returned an error";
603
+ if (typeof l.status === "string" && !/^(ok|success)$/i.test(l.status)) {
604
+ return msg || `the store list request returned status "${l.status}"`;
323
605
  }
324
- if (list.error) return msg || "the store list request returned an error";
325
- if (typeof list.raw === "string" && list.raw.trim()) return list.raw.trim();
606
+ if (l.error) return msg || "the store list request returned an error";
607
+ if (typeof l.raw === "string" && l.raw.trim()) return l.raw.trim();
326
608
  return null;
327
609
  }
328
610
 
329
611
  /**
330
- * Extract card c1's broker-identity remediation signal from a `client_list`
612
+ * Extract the broker-identity remediation signal from a `client_list`
331
613
  * result. When the identity resolved ZERO stores, the server MAY carry:
332
614
  * - `brokerStatus`: 'unlinked' | 'unconfigured' | 'broker_error' — present only
333
615
  * when the empty scope is a BROKER-IDENTITY problem (not an entitlement one);
@@ -341,7 +623,7 @@ export function storeListError(list) {
341
623
  * @returns {{ brokerStatus: string|null, nextAction: string|null }}
342
624
  */
343
625
  export function brokerRemediation(list) {
344
- const c = list && typeof list === "object" && !Array.isArray(list) ? list : null;
626
+ const c = /** @type {any} */ (list && typeof list === "object" && !Array.isArray(list) ? list : null);
345
627
  const brokerStatus = c && typeof c.brokerStatus === "string" ? c.brokerStatus : null;
346
628
  const nextAction =
347
629
  c && typeof c.nextAction === "string" && c.nextAction.trim() ? c.nextAction.trim() : null;
@@ -350,8 +632,8 @@ export function brokerRemediation(list) {
350
632
 
351
633
  /**
352
634
  * Status-aware human guidance for an authenticated identity that resolved ZERO
353
- * stores (card c2 — the fix for the misleading "may still be propagating / ask for
354
- * a store invite" copy that dead-ended an UNLINKED identity). Driven by card c1's
635
+ * stores (fixes the misleading "may still be propagating / ask for
636
+ * a store invite" copy that dead-ended an UNLINKED identity). Driven by
355
637
  * `brokerStatus`/`nextAction`:
356
638
  * - `unlinked` → the identity isn't linked to the ToT broker yet, so no scope can
357
639
  * resolve. This is NOT an entitlement problem — point at `tot link` (the
@@ -376,7 +658,7 @@ export function noStoresGuidance(list, { linkHint = "tot link" } = {}) {
376
658
 
377
659
  if (brokerStatus === "unlinked") {
378
660
  // The actionable terminal step beats the server's identity_link_begin/poll
379
- // MCP-tool wording for a CLI user, so lead with `tot link` (c2 optional-arm
661
+ // MCP-tool wording for a CLI user, so lead with `tot link` (the optional-arm
380
662
  // directive). Fall back to the server string only if there's no link action.
381
663
  return {
382
664
  brokerStatus,