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

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 (47) hide show
  1. package/README.md +12 -9
  2. package/bin/tot.mjs +219 -44
  3. package/package.json +7 -2
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +2 -2
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +137 -0
  8. package/src/commands/accept.mjs +736 -0
  9. package/src/commands/app/dev.mjs +7 -3
  10. package/src/commands/app/index.mjs +2 -2
  11. package/src/commands/branches.mjs +297 -0
  12. package/src/commands/cleanup.mjs +269 -0
  13. package/src/commands/clone.mjs +713 -0
  14. package/src/commands/dev.mjs +441 -93
  15. package/src/commands/doctor.mjs +4 -3
  16. package/src/commands/git-credential.mjs +180 -0
  17. package/src/commands/go-live.mjs +486 -0
  18. package/src/commands/grants.mjs +14 -7
  19. package/src/commands/hotfix.mjs +428 -0
  20. package/src/commands/link.mjs +225 -0
  21. package/src/commands/login.mjs +12 -8
  22. package/src/commands/pr.mjs +425 -0
  23. package/src/commands/preview-build.mjs +225 -0
  24. package/src/commands/preview.mjs +80 -0
  25. package/src/commands/retire.mjs +203 -0
  26. package/src/commands/revert.mjs +322 -0
  27. package/src/commands/rollback.mjs +403 -0
  28. package/src/commands/ship.mjs +517 -0
  29. package/src/commands/start.mjs +91 -29
  30. package/src/commands/submit.mjs +1360 -131
  31. package/src/commands/sync.mjs +203 -0
  32. package/src/commands/validate.mjs +11 -5
  33. package/src/commands/whoami.mjs +6 -2
  34. package/src/context.mjs +2 -2
  35. package/src/dev-heartbeat.mjs +2 -1
  36. package/src/errors.mjs +8 -4
  37. package/src/git-credential.mjs +185 -0
  38. package/src/mcp.mjs +6 -1
  39. package/src/no-gitea-links.test.mjs +55 -0
  40. package/src/oauth.mjs +26 -11
  41. package/src/obstacle-beacon.cjs +3 -3
  42. package/src/obstacle.mjs +1 -1
  43. package/src/plan.mjs +262 -0
  44. package/src/sample.mjs +30 -4
  45. package/src/validate.mjs +56 -0
  46. package/src/viewer-session.mjs +118 -0
  47. package/src/commands/checkout.mjs +0 -330
@@ -0,0 +1,713 @@
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
+ import { CREDENTIAL_HELPER, splitAuthedRemote, basicAuthExtraHeader } from "../git-credential.mjs";
58
+
59
+ const execFileP = promisify(execFile);
60
+
61
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
62
+
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 }} */
65
+ const a = {
66
+ tenant: null,
67
+ dir: null,
68
+ tag: "main",
69
+ pr: null,
70
+ remoteOnly: false,
71
+ mcp: null,
72
+ printRemote: false,
73
+ help: false,
74
+ };
75
+ for (let i = 0; i < argv.length; i++) {
76
+ const t = argv[i];
77
+ if (t === "--tag") a.tag = argv[++i];
78
+ else if (t === "--pr") a.pr = argv[++i];
79
+ else if (t === "--remote-only") a.remoteOnly = true;
80
+ else if (t === "--mcp") a.mcp = argv[++i];
81
+ else if (t === "--print-remote") a.printRemote = true;
82
+ else if (t === "--help" || t === "-h") a.help = true;
83
+ // Positional, git-clone style: `tot clone <tenant> [<dir>]`.
84
+ else if (!t.startsWith("--")) {
85
+ if (!a.tenant) a.tenant = t;
86
+ else if (!a.dir) a.dir = t;
87
+ }
88
+ }
89
+ return a;
90
+ }
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
+
108
+ const USAGE = `tot clone — clone a tenant store you can build on (mirrors \`git clone\`)
109
+
110
+ tot clone list the stores you can build on
111
+ tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
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>)
117
+
118
+ Options:
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.
122
+ --remote-only don't clone; just mint + print the clone URL for <tenant>.
123
+ --mcp <url> MCP base URL. Default: env MCP_BASE_URL / TOT_MCP_URL, else
124
+ ${DEFAULT_MCP_URL}.
125
+ --print-remote with --remote-only, also print the authenticated remote
126
+ (contains a live token!).`;
127
+
128
+ /** Redact known secrets from any string before it hits the terminal. */
129
+ function makeRedactor(env) {
130
+ const secrets = [env.TOT_API_KEY, env.TOT_SECRET_KEY].filter(Boolean);
131
+ return (s) => {
132
+ let o = String(s);
133
+ for (const x of secrets) o = o.split(x).join("***");
134
+ return o;
135
+ };
136
+ }
137
+
138
+ /**
139
+ * @param {string[]} argv - args AFTER `checkout`
140
+ * @param {import("../context.mjs").detectContext extends (...a:any)=>infer R ? R : any} ctx
141
+ */
142
+ export async function run(argv, ctx) {
143
+ const env = process.env;
144
+ const args = parseArgs(argv);
145
+ if (args.help) {
146
+ console.log(USAGE);
147
+ return 0;
148
+ }
149
+
150
+ // Infer the tenant from a standalone checkout when run without one.
151
+ if (!args.tenant && ctx.mode === "checkout" && ctx.tenant) {
152
+ args.tenant = ctx.tenant;
153
+ }
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
+
163
+ // git-clone semantics: unless you explicitly asked for --remote-only, cloning
164
+ // is the default, and the target dir defaults to the tenant name (like
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);
168
+
169
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
170
+ const redact = makeRedactor(env);
171
+ const client = createMcpClient(baseUrl);
172
+
173
+ try {
174
+ // Attach auth in the right order relative to the handshake (developer bearer
175
+ // BEFORE initialize) — see establishSession. When there's no session yet and
176
+ // we're on a TTY, offer to sign in inline and retry once, so a not-signed-in
177
+ // developer isn't dead-ended at "run tot login, then re-run".
178
+ try {
179
+ await establishSession(client, { env });
180
+ } catch (e) {
181
+ if (e instanceof AuthUnavailableError && e.reason === "missing") {
182
+ const signedIn = await offerSignIn(client.mcpUrl, env, {});
183
+ if (!signedIn) throw e; // declined/non-TTY → the crisp error below
184
+ await establishSession(client, { env }); // retry once, in-flow
185
+ } else {
186
+ throw e;
187
+ }
188
+ }
189
+ console.error(`~ signed in → ${client.mcpUrl}`);
190
+
191
+ // No tenant → list the stores this identity can build on and stop.
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
+ }
197
+ const list = await client.callTool("client_list", {});
198
+ printClientList(list);
199
+ return 0;
200
+ }
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
+
235
+ console.error(`~ client_switch → ${args.tenant}`);
236
+ console.error(
237
+ `~ tenant_checkout (tag=${args.tag}) — MCP mints a fresh repo-scoped credential`,
238
+ );
239
+ const res = await checkoutTenant(client, {
240
+ tenant: args.tenant,
241
+ tag: args.tag,
242
+ cloneDir,
243
+ redact,
244
+ });
245
+ console.log(`\n+ ready. repo: ${res.cloneUrl || res.publicUrl}`);
246
+
247
+ if (res.cloned) {
248
+ console.log(`+ cloned. HEAD: ${res.head}`);
249
+ console.log(`\nYour local working clone is at ${res.dir}.`);
250
+ console.log(` cd ${res.dir} && tot dev # run it locally with save→reload`);
251
+ console.log(` (origin has no token — \`tot\` mints one fresh at fetch/push time)`);
252
+ return 0;
253
+ }
254
+
255
+ console.log(`\nPublic clone URL (no credential): ${res.publicUrl}`);
256
+ console.log(`Drop --remote-only to clone with the authenticated remote configured.`);
257
+ if (args.printRemote) {
258
+ console.log(
259
+ `\nAUTHENTICATED remote (contains a live token — handle carefully):\n${res.gitRemote}`,
260
+ );
261
+ }
262
+ return 0;
263
+ } catch (e) {
264
+ if (e instanceof AuthUnavailableError) {
265
+ console.error(formatError(e));
266
+ return 1;
267
+ }
268
+ if (e instanceof CliError) {
269
+ console.error(formatError(e));
270
+ return e.exitCode ?? 1;
271
+ }
272
+ console.error(fail(`checkout failed: ${redact(String(e?.message || e))}`));
273
+ return 1;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * The privileged checkout core, composable in-process (used by `tot clone`
279
+ * and by `tot start`): client_switch → tenant_checkout → optionally clone. The
280
+ * MCP mints a fresh, repo-scoped push credential each time. Assumes `client` is
281
+ * already initialized + has a validated session.
282
+ *
283
+ * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
284
+ * @param {{ tenant: string, tag?: string, cloneDir?: string|null, redact?: (s:string)=>string }} opts
285
+ * @returns {Promise<{ gitRemote: string, cloneUrl: string|null, publicUrl: string,
286
+ * cloned: boolean, dir: string|null, head: string|null }>}
287
+ */
288
+ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
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
+ }
306
+ const checkout = await client.callTool("tenant_checkout", { tenant, tag });
307
+
308
+ // A non-checkout result (not provisioned / not entitled / failed) must surface
309
+ // the MCP's human message + a concrete next step, NEVER a raw JSON.stringify
310
+ // dump (James's 2026-07-19 first-experience failure).
311
+ const err = checkoutError(checkout);
312
+ if (err) {
313
+ throw new CliError(redact(err.message), { next: err.next });
314
+ }
315
+ const gitRemote = checkout.gitRemote;
316
+ const cloneUrl = checkout.cloneUrl ?? null;
317
+ const u = new URL(gitRemote);
318
+ const publicUrl = `${u.protocol}//${u.host}${u.pathname}`;
319
+
320
+ if (!cloneDir) {
321
+ return { gitRemote, cloneUrl, publicUrl, cloned: false, dir: null, head: null };
322
+ }
323
+ const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact);
324
+ return { gitRemote, cloneUrl, publicUrl, cloned: true, dir, head };
325
+ }
326
+
327
+ /**
328
+ * git clone the authenticated remote into `dir` — WITHOUT ever writing the
329
+ * live token to `.git/config` (unit u10). 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 pre-u10 shape this fixes), 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.
346
+ */
347
+ async function cloneRepo(gitRemote, dir, redact) {
348
+ const git = async (cargs) => (await execFileP("git", cargs)).stdout.toString();
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
358
+ try {
359
+ await git(cloneArgs);
360
+ } catch (e) {
361
+ // Beacon the cockpit before we surface the error — covers this path for both
362
+ // `tot clone` and `tot start` (which clones through here). Awaited so the
363
+ // packet lands before the process prints + exits; swallowed either way.
364
+ await emitObstacle("clone-failed");
365
+ throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
366
+ next: `check the target dir is empty and you can reach the remote, then re-run`,
367
+ });
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
+ }
375
+ const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
376
+ writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
377
+ return { dir, head };
378
+ }
379
+
380
+ /**
381
+ * The cross-tenant PR-clone core (unit u6), 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
+
508
+ /**
509
+ * Classify a `tenant_checkout` tool result: null when it's a genuine, usable
510
+ * checkout (carries a gitRemote), otherwise a human { message, next } pair so
511
+ * callers surface the MCP's own words + a concrete next step instead of dumping
512
+ * raw JSON (James's 2026-07-19 first-experience failure: a not-provisioned store
513
+ * printed JSON.stringify(checkout)). callTool unwraps the tool result to its
514
+ * structuredContent / parsed text, so a failure surfaces as an error-ish status
515
+ * ('forbidden' | 'invalid_input' | 'checkout_failed' | 'error'), a `message`, or
516
+ * simply a missing gitRemote. When the repo isn't provisioned yet we speak to the
517
+ * INVITED DEVELOPER ("your store isn't set up yet"), not the operator — dropping
518
+ * the `repo_provision` jargon the MCP aims at whoever provisions. Any other
519
+ * failure surfaces the MCP's own message. Pure + exported so it's unit-tested
520
+ * without any I/O.
521
+ * @param {unknown} checkout
522
+ * @returns {{ message: string, next: string }|null}
523
+ */
524
+ export function checkoutError(checkout) {
525
+ const c = /** @type {any} */ (checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null);
526
+ if (c && c.gitRemote) return null; // a usable checkout — never an error
527
+ const msg =
528
+ (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
529
+ (c && typeof c.raw === "string" && c.raw.trim() ? c.raw.trim() : null) ||
530
+ null;
531
+ // The store's repo isn't provisioned yet — it isn't set up for this developer.
532
+ if (typeof msg === "string" && /not provisioned/i.test(msg)) {
533
+ return {
534
+ message: "your store isn't set up on Token of Trust yet",
535
+ next: "ask your Token of Trust contact to finish setting up your store, then re-run",
536
+ };
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
+ }
554
+ return {
555
+ message: msg || "the store checkout couldn't be completed",
556
+ next: "confirm you're entitled to this store — `tot clone` (lists your stores)",
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Normalize the (shape-varying) `client_list` response into a plain, sorted
562
+ * list of the stores this identity can build on. Shared by `tot clone`'s
563
+ * listing and `tot start`'s auto-pick so both read the same fields.
564
+ * @param {unknown} list
565
+ * @returns {Array<{ id: string, name: string, raw: any }>}
566
+ */
567
+ export function normalizeStores(list) {
568
+ const l = /** @type {any} */ (list);
569
+ const rows = Array.isArray(l) ? l : l?.clients || l?.tenants || [];
570
+ if (!Array.isArray(rows)) return [];
571
+ return rows
572
+ .map((r) => ({
573
+ id: r?.tenant || r?.id || r?.clientId || r?.appDomain || null,
574
+ name: r?.displayName || r?.name || "",
575
+ raw: r,
576
+ }))
577
+ .filter((r) => r.id);
578
+ }
579
+
580
+ /**
581
+ * Detect whether a `client_list` tool result is an ERROR result rather than a
582
+ * genuinely empty-but-successful store list — so callers surface it (identity +
583
+ * origin + reason, per fb-1783905718950-f45zg9) instead of collapsing it to an
584
+ * empty list and dead-ending at "ask for an invite". callTool unwraps a tool
585
+ * result to its structuredContent / parsed text, so an error surfaces as a
586
+ * status other than ok/success, an `error`/`isError` field, or an unparseable
587
+ * `raw` text blob. A response that carries a resolvable store array (even empty)
588
+ * is always a success. Returns a short human reason, or null when it's not an
589
+ * error. Pure + exported so it's unit-tested without any I/O.
590
+ * @param {unknown} list
591
+ * @returns {string|null}
592
+ */
593
+ export function storeListError(list) {
594
+ if (list == null || typeof list !== "object" || Array.isArray(list)) return null;
595
+ const l = /** @type {any} */ (list);
596
+ // A resolvable store array present → it succeeded, never an error.
597
+ if (Array.isArray(l.clients) || Array.isArray(l.tenants)) return null;
598
+ const msg =
599
+ l.message ||
600
+ (typeof l.error === "string" ? l.error : l.error?.message) ||
601
+ null;
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}"`;
605
+ }
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();
608
+ return null;
609
+ }
610
+
611
+ /**
612
+ * Extract card c1's broker-identity remediation signal from a `client_list`
613
+ * result. When the identity resolved ZERO stores, the server MAY carry:
614
+ * - `brokerStatus`: 'unlinked' | 'unconfigured' | 'broker_error' — present only
615
+ * when the empty scope is a BROKER-IDENTITY problem (not an entitlement one);
616
+ * ABSENT for the genuine linked-but-zero-grants case.
617
+ * - `nextAction`: a status-specific human remediation string the server owns
618
+ * (single source of truth; e.g. an unlinked identity is told to finish
619
+ * linking, an operator sees a diagnostic, zero-grants sees NO_TENANTS_MESSAGE).
620
+ * Returns { brokerStatus, nextAction } with nulls when absent. Pure + exported so
621
+ * it's unit-tested without any I/O.
622
+ * @param {unknown} list
623
+ * @returns {{ brokerStatus: string|null, nextAction: string|null }}
624
+ */
625
+ export function brokerRemediation(list) {
626
+ const c = /** @type {any} */ (list && typeof list === "object" && !Array.isArray(list) ? list : null);
627
+ const brokerStatus = c && typeof c.brokerStatus === "string" ? c.brokerStatus : null;
628
+ const nextAction =
629
+ c && typeof c.nextAction === "string" && c.nextAction.trim() ? c.nextAction.trim() : null;
630
+ return { brokerStatus, nextAction };
631
+ }
632
+
633
+ /**
634
+ * Status-aware human guidance for an authenticated identity that resolved ZERO
635
+ * stores (card c2 — the fix for the misleading "may still be propagating / ask for
636
+ * a store invite" copy that dead-ended an UNLINKED identity). Driven by card c1's
637
+ * `brokerStatus`/`nextAction`:
638
+ * - `unlinked` → the identity isn't linked to the ToT broker yet, so no scope can
639
+ * resolve. This is NOT an entitlement problem — point at `tot link` (the
640
+ * terminal action), NOT "ask for a store invite".
641
+ * - `unconfigured` | `broker_error` → an operator/diagnostic condition; surface
642
+ * the server's own `nextAction`, never "ask for an invite".
643
+ * - no brokerStatus → the genuine linked-but-zero-grants case; keep the existing
644
+ * "invite may still be propagating / ask for one" wording (NO_TENANTS_MESSAGE),
645
+ * preferring the server's `nextAction` when present.
646
+ *
647
+ * Prefers the server's `nextAction` as the concrete `next` step (single source of
648
+ * truth) and falls back to sensible local copy when it's absent (older server).
649
+ * `linkHint` names the terminal action an unlinked identity is pointed at — the
650
+ * built-in `tot link`; a caller that didn't build `tot link` passes an MCP-client
651
+ * phrasing instead. Pure + exported so it's unit-tested without any I/O.
652
+ * @param {unknown} list
653
+ * @param {{ linkHint?: string }} [opts]
654
+ * @returns {{ brokerStatus: string|null, headline: string, next: string }}
655
+ */
656
+ export function noStoresGuidance(list, { linkHint = "tot link" } = {}) {
657
+ const { brokerStatus, nextAction } = brokerRemediation(list);
658
+
659
+ if (brokerStatus === "unlinked") {
660
+ // The actionable terminal step beats the server's identity_link_begin/poll
661
+ // MCP-tool wording for a CLI user, so lead with `tot link` (c2 optional-arm
662
+ // directive). Fall back to the server string only if there's no link action.
663
+ return {
664
+ brokerStatus,
665
+ headline: "your Token of Trust identity isn't linked yet, so no stores could be resolved",
666
+ next: linkHint
667
+ ? `run \`${linkHint}\` to finish linking your identity, then re-run`
668
+ : nextAction || "finish linking your identity (identity_link_begin), then re-run",
669
+ };
670
+ }
671
+ if (brokerStatus === "unconfigured" || brokerStatus === "broker_error") {
672
+ // Operator/diagnostic conditions — surface the server's own words; do NOT tell
673
+ // the developer to "ask for an invite" (it isn't an entitlement problem).
674
+ return {
675
+ brokerStatus,
676
+ headline: "no stores could be resolved — the Token of Trust identity broker had a problem",
677
+ next: nextAction || "run `tot whoami` for details, or contact your Token of Trust operator",
678
+ };
679
+ }
680
+ // No brokerStatus → the genuine linked-but-zero-grants case.
681
+ return {
682
+ brokerStatus: null,
683
+ headline: "you have no stores to build on yet",
684
+ next:
685
+ nextAction ||
686
+ "if you were just invited, it may still be propagating — try again in a minute; " +
687
+ "otherwise ask your Token of Trust contact for a store invite (see `tot whoami`)",
688
+ };
689
+ }
690
+
691
+ function printClientList(list) {
692
+ const err = storeListError(list);
693
+ const stores = normalizeStores(list);
694
+ if (err && stores.length === 0) {
695
+ console.log(`\nCouldn't list your stores: ${err}`);
696
+ console.log("Run `tot whoami` to check your session, or `tot login` again.");
697
+ return;
698
+ }
699
+ if (stores.length === 0) {
700
+ // Status-aware, human-readable guidance — NEVER a raw JSON dump (the old
701
+ // behaviour, James's 2026-07-19 failure mode).
702
+ const g = noStoresGuidance(list);
703
+ const line = g.headline.charAt(0).toUpperCase() + g.headline.slice(1);
704
+ console.log(`\n${line}.`);
705
+ console.log(`Next: ${g.next}`);
706
+ return;
707
+ }
708
+ console.log("\nStores you can build on:\n");
709
+ for (const s of stores) {
710
+ console.log(` ${s.id}${s.name ? ` — ${s.name}` : ""}`);
711
+ }
712
+ console.log(`\nNext: tot clone <tenant>`);
713
+ }