@tokenoftrust/cli 1.4.0 → 1.4.1

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.
@@ -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 (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
+ *
26
47
  * Dependency-free (global fetch + `git` via child_process).
27
48
  */
28
49
  import { execFile } from "node:child_process";
@@ -33,6 +54,7 @@ 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
 
@@ -43,6 +65,7 @@ function parseArgs(argv) {
43
65
  tenant: null,
44
66
  dir: null,
45
67
  tag: "main",
68
+ pr: null,
46
69
  remoteOnly: false,
47
70
  mcp: null,
48
71
  printRemote: false,
@@ -51,6 +74,7 @@ function parseArgs(argv) {
51
74
  for (let i = 0; i < argv.length; i++) {
52
75
  const t = argv[i];
53
76
  if (t === "--tag") a.tag = argv[++i];
77
+ else if (t === "--pr") a.pr = argv[++i];
54
78
  else if (t === "--remote-only") a.remoteOnly = true;
55
79
  else if (t === "--mcp") a.mcp = argv[++i];
56
80
  else if (t === "--print-remote") a.printRemote = true;
@@ -64,14 +88,36 @@ function parseArgs(argv) {
64
88
  return a;
65
89
  }
66
90
 
91
+ /**
92
+ * Parse + validate the `--pr` value into a positive integer, or null when
93
+ * absent. Throws a CliError (never a raw NaN downstream) on garbage input.
94
+ * Pure. @param {unknown} raw @returns {number|null}
95
+ */
96
+ export function parsePrNumber(raw) {
97
+ if (raw == null) return null;
98
+ const n = Number(raw);
99
+ if (!Number.isInteger(n) || n < 1) {
100
+ throw new CliError(`invalid --pr value "${raw}" — must be a positive PR number`, {
101
+ next: "tot clone <tenant> --pr <N> [dir]",
102
+ });
103
+ }
104
+ return n;
105
+ }
106
+
67
107
  const USAGE = `tot clone — clone a tenant store you can build on (mirrors \`git clone\`)
68
108
 
69
109
  tot clone list the stores you can build on
70
110
  tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
71
111
  tot clone <tenant> <dir> clone into <dir>
112
+ tot clone <tenant> --pr <N> [dir] OPERATOR: clone PR #<N>'s head, READ-ONLY
113
+ (works even on a tenant you don't own/aren't a
114
+ member of, when authorized; dir defaults to
115
+ <tenant>-pr<N>)
72
116
 
73
117
  Options:
74
118
  --tag <tag> which repo (repo = "<tenant>-<tag>"). Default: main.
119
+ --pr <N> clone PR #<N>'s head instead of the default branch — a
120
+ READ-ONLY cross-tenant credential, not a push checkout.
75
121
  --remote-only don't clone; just mint + print the clone URL for <tenant>.
76
122
  --mcp <url> MCP base URL. Default: env MCP_BASE_URL / TOT_MCP_URL, else
77
123
  ${DEFAULT_MCP_URL}.
@@ -105,10 +151,19 @@ export async function run(argv, ctx) {
105
151
  args.tenant = ctx.tenant;
106
152
  }
107
153
 
154
+ let pr;
155
+ try {
156
+ pr = parsePrNumber(args.pr);
157
+ } catch (e) {
158
+ console.error(formatError(e));
159
+ return e.exitCode ?? 1;
160
+ }
161
+
108
162
  // git-clone semantics: unless you explicitly asked for --remote-only, cloning
109
163
  // 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;
164
+ // `git clone <url>` deriving the dir from the repo basename) — or, with
165
+ // --pr, to "<tenant>-pr<N>" so a PR clone never collides with a plain one.
166
+ const cloneDir = args.remoteOnly ? null : args.dir || (pr ? `${args.tenant}-pr${pr}` : args.tenant);
112
167
 
113
168
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
114
169
  const redact = makeRedactor(env);
@@ -134,11 +189,48 @@ export async function run(argv, ctx) {
134
189
 
135
190
  // No tenant → list the stores this identity can build on and stop.
136
191
  if (!args.tenant) {
192
+ if (pr) {
193
+ console.error(fail("`tot clone --pr` needs a tenant", "tot clone <tenant> --pr <N> [dir]"));
194
+ return 2;
195
+ }
137
196
  const list = await client.callTool("client_list", {});
138
197
  printClientList(list);
139
198
  return 0;
140
199
  }
141
200
 
201
+ // --pr ALWAYS takes the cross-tenant read-credential path, even for your own
202
+ // tenant — tenant_checkout has no PR-ref parameter to select, so there is no
203
+ // membership-based fork here (see the file header comment).
204
+ if (pr) {
205
+ console.error(
206
+ `~ repo_read_credential (tenant=${args.tenant}) — MCP mints a read-only credential`,
207
+ );
208
+ const res = await cloneCrossTenantPr(client, {
209
+ tenant: args.tenant,
210
+ pr,
211
+ cloneDir,
212
+ redact,
213
+ });
214
+ console.log(`\n+ ready (read-only). repo: ${res.publicUrl}`);
215
+
216
+ if (res.cloned) {
217
+ console.log(`+ cloned PR #${pr}. HEAD: ${res.head}`);
218
+ console.log(`\nYour local read-only clone of PR #${pr} is at ${res.dir}.`);
219
+ console.log(` (this credential can only pull, never push — for your own tenant's`);
220
+ console.log(` push checkout, run \`tot clone ${args.tenant}\` without --pr)`);
221
+ return 0;
222
+ }
223
+
224
+ console.log(`\nPublic clone URL (no credential): ${res.publicUrl}`);
225
+ console.log(`Drop --remote-only to clone PR #${pr} with the read-only remote configured.`);
226
+ if (args.printRemote) {
227
+ console.log(
228
+ `\nREAD-ONLY remote (contains a live token — handle carefully):\n${res.gitRemote}`,
229
+ );
230
+ }
231
+ return 0;
232
+ }
233
+
142
234
  console.error(`~ client_switch → ${args.tenant}`);
143
235
  console.error(
144
236
  `~ tenant_checkout (tag=${args.tag}) — MCP mints a fresh repo-scoped credential`,
@@ -153,9 +245,9 @@ export async function run(argv, ctx) {
153
245
 
154
246
  if (res.cloned) {
155
247
  console.log(`+ cloned. HEAD: ${res.head}`);
156
- console.log(`\nYour local working clone is at ${res.dir} with an authenticated remote.`);
248
+ console.log(`\nYour local working clone is at ${res.dir}.`);
157
249
  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)`);
250
+ console.log(` (origin has no token \`tot\` mints one fresh at fetch/push time)`);
159
251
  return 0;
160
252
  }
161
253
 
@@ -194,6 +286,22 @@ export async function run(argv, ctx) {
194
286
  */
195
287
  export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
196
288
  await client.callTool("client_switch", { tenant });
289
+ // vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
290
+ // storefront version-control app BEFORE checkout, so tenant_checkout can resolve
291
+ // `appForCaller` for them instead of 403-ing "No version-control app is bound." The
292
+ // human's OWN session self-triggers it (no subject_token — the subject is the
293
+ // caller's already-verified identity); the MCP entitlement-gates + authors the bind.
294
+ // Best-effort + FAIL-OPEN: a missing tool / cold MCP / any fault must never block a
295
+ // checkout that would otherwise succeed, and if the developer is still unbound the
296
+ // tenant_checkout error below (via checkoutError) is the honest backstop. Binding is
297
+ // also ensured at the authority seams (native OAuth sign-in) + backfill; this line
298
+ // self-heals a legacy session that predates them.
299
+ try {
300
+ // Wire name is `identity_bind` (tot-mcp taxonomy: <subject>_<action>).
301
+ await client.callTool("identity_bind", {});
302
+ } catch {
303
+ /* fail-open — see above */
304
+ }
197
305
  const checkout = await client.callTool("tenant_checkout", { tenant, tag });
198
306
 
199
307
  // A non-checkout result (not provisioned / not entitled / failed) must surface
@@ -216,17 +324,38 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
216
324
  }
217
325
 
218
326
  /**
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.
327
+ * git clone the authenticated remote into `dir` WITHOUT ever writing the
328
+ * live token to `.git/config` (unit u10). The MCP mints `gitRemote` as a
329
+ * basic-auth URL (`user:token@host`); rather than passing that straight to
330
+ * `git clone` (which records exactly the URL it was given as `origin`, token
331
+ * and all the pre-u10 shape this fixes), the token is handed to git
332
+ * EPHEMERALLY via a one-shot `http.extraheader` (same mechanism
333
+ * pushPreviewRef in submit.mjs uses for a push) while the clone SOURCE is
334
+ * already the tokenless public URL — so `origin` comes out tokenless from
335
+ * the very first commit. The new checkout's `credential.helper` is then
336
+ * configured to `tot git-credential`, so every later fetch/push mints a
337
+ * fresh token through the CLI's own login session instead of ever needing
338
+ * one persisted on disk (a later clone no longer needs to "rotate" anything
339
+ * — the old flow's `.git/config` token is simply never written).
340
+ *
341
+ * Runs git as a NON-BLOCKING child process (promisified execFile) so the
342
+ * clone doesn't stall the Node event loop — `tot start` runs this inside a
343
+ * `Promise.all([...])` alongside the renderer prefetch, and a synchronous
344
+ * clone would serialize what's meant to overlap. Throws CliError on failure.
224
345
  */
225
346
  async function cloneRepo(gitRemote, dir, redact) {
226
347
  const git = async (cargs) => (await execFileP("git", cargs)).stdout.toString();
227
348
  console.log(`+ git clone → ${dir}`);
349
+ const cred = splitAuthedRemote(gitRemote);
350
+ const cloneArgs = cred
351
+ ? [
352
+ "-c", `http.extraheader=${basicAuthExtraHeader(cred.username, cred.token)}`,
353
+ "-c", "credential.helper=",
354
+ "clone", cred.publicUrl, dir,
355
+ ]
356
+ : ["clone", gitRemote, dir]; // no embedded token (e.g. already public) — clone as given
228
357
  try {
229
- await git(["clone", gitRemote, dir]);
358
+ await git(cloneArgs);
230
359
  } catch (e) {
231
360
  // Beacon the cockpit before we surface the error — covers this path for both
232
361
  // `tot clone` and `tot start` (which clones through here). Awaited so the
@@ -236,11 +365,145 @@ async function cloneRepo(gitRemote, dir, redact) {
236
365
  next: `check the target dir is empty and you can reach the remote, then re-run`,
237
366
  });
238
367
  }
368
+ try {
369
+ await git(["-C", dir, "config", "--local", "credential.helper", CREDENTIAL_HELPER]);
370
+ } catch {
371
+ // Best-effort — a checkout missing the helper still WORKS (the token just
372
+ // isn't self-healing yet); the next `tot preview`/`tot sync` migrates it.
373
+ }
239
374
  const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
240
375
  writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
241
376
  return { dir, head };
242
377
  }
243
378
 
379
+ /**
380
+ * The cross-tenant PR-clone core (unit u6), composable in-process exactly like
381
+ * `checkoutTenant`: mint a READ-ONLY credential via `repo_read_credential`, then
382
+ * (optionally) clone it and materialize PR #<pr>'s head. This is the path `--pr`
383
+ * ALWAYS takes — own tenant or foreign — because tenant_checkout has no PR-ref
384
+ * parameter (see the file header). Assumes `client` is already initialized +
385
+ * has a validated session.
386
+ *
387
+ * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
388
+ * @param {{ tenant: string, pr: number, cloneDir?: string|null, redact?: (s:string)=>string }} opts
389
+ * @returns {Promise<{ gitRemote: string, publicUrl: string, cloned: boolean,
390
+ * dir: string|null, head: string|null }>}
391
+ */
392
+ export async function cloneCrossTenantPr(
393
+ client,
394
+ { tenant, pr, cloneDir = null, redact = (s) => s },
395
+ ) {
396
+ const minted = await client.callTool("repo_read_credential", { tenant });
397
+
398
+ // A non-mint result (unauthenticated / forbidden / invalid_input / error) must
399
+ // surface the MCP's OWN denial reason + a concrete next step — never a raw
400
+ // JSON dump, and never a CLI-side re-derivation of the authorization call
401
+ // (the server's `decideContentRead` is the sole authority here).
402
+ const err = readCredentialError(minted);
403
+ if (err) {
404
+ throw new CliError(redact(err.message), { next: err.next });
405
+ }
406
+
407
+ const repos = Array.isArray(minted.repos) ? minted.repos : [];
408
+ const target = repos.find((r) => r.repo === tenant || r.owner === tenant) ?? repos[0];
409
+ if (!target || !target.gitRemote) {
410
+ throw new CliError(
411
+ `repo_read_credential minted no usable credential for tenant "${tenant}"`,
412
+ { next: "confirm the tenant name, then re-run" },
413
+ );
414
+ }
415
+ const gitRemote = target.gitRemote;
416
+ const u = new URL(gitRemote);
417
+ const publicUrl = `${u.protocol}//${u.host}${u.pathname}`;
418
+
419
+ if (!cloneDir) {
420
+ return { gitRemote, publicUrl, cloned: false, dir: null, head: null };
421
+ }
422
+ const { dir, head } = await cloneRepoAtPrRef(gitRemote, cloneDir, pr, redact);
423
+ return { gitRemote, publicUrl, cloned: true, dir, head };
424
+ }
425
+
426
+ /**
427
+ * git clone the authenticated (READ-ONLY) remote into `dir`, then materialize
428
+ * PR #<pr>'s head by NUMBER — Gitea exposes every pull request at the standard
429
+ * `refs/pull/<N>/head` ref, so this needs no separate PR-metadata lookup (see
430
+ * the file header on why `repo_pr_read` isn't used here). Runs git as a
431
+ * NON-BLOCKING child process (promisified execFile), same as `cloneRepo`.
432
+ * `deps.git` is injectable for tests (an async `(cargs) => stdout` runner);
433
+ * defaults to the real `git` binary. Throws CliError on failure.
434
+ * @param {string} gitRemote
435
+ * @param {string} dir
436
+ * @param {number} pr
437
+ * @param {(s: string) => string} redact
438
+ * @param {{ git?: (cargs: string[]) => Promise<string> }} [deps]
439
+ */
440
+ export async function cloneRepoAtPrRef(gitRemote, dir, pr, redact, deps = {}) {
441
+ const git = deps.git || (async (cargs) => (await execFileP("git", cargs)).stdout.toString());
442
+ console.log(`+ git clone → ${dir}`);
443
+ try {
444
+ await git(["clone", gitRemote, dir]);
445
+ } catch (e) {
446
+ await emitObstacle("clone-failed");
447
+ throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
448
+ next: `check the target dir is empty and you can reach the remote, then re-run`,
449
+ });
450
+ }
451
+ const prRef = `refs/pull/${pr}/head`;
452
+ console.log(`+ git fetch ${prRef}`);
453
+ try {
454
+ await git(["-C", dir, "fetch", "origin", prRef]);
455
+ await git(["-C", dir, "checkout", "-B", `pr-${pr}`, "FETCH_HEAD"]);
456
+ } catch (e) {
457
+ throw new CliError(
458
+ `couldn't fetch PR #${pr}: ${redact(String(e.stderr || e.message || e))}`,
459
+ { next: `confirm PR #${pr} exists on this tenant — \`tot pr list --tenant <t>\`, then re-run` },
460
+ );
461
+ }
462
+ const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
463
+ writeNvmrc(dir);
464
+ return { dir, head };
465
+ }
466
+
467
+ /**
468
+ * Classify a `repo_read_credential` tool result: null when it's a genuine,
469
+ * usable mint (carries at least one repo with a gitRemote), otherwise a human
470
+ * { message, next } pair — mirrors `checkoutError`'s contract exactly (never a
471
+ * raw JSON dump; the MCP's own denial reason + a concrete next step). Pure +
472
+ * exported so it's unit-tested without any I/O.
473
+ * @param {unknown} minted
474
+ * @returns {{ message: string, next: string }|null}
475
+ */
476
+ export function readCredentialError(minted) {
477
+ const c = minted && typeof minted === "object" && !Array.isArray(minted) ? minted : null;
478
+ if (c && Array.isArray(c.repos) && c.repos.some((r) => r && r.gitRemote)) return null;
479
+ const msg =
480
+ (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
481
+ (c && typeof c.raw === "string" && c.raw.trim() ? c.raw.trim() : null) ||
482
+ null;
483
+ if (typeof msg === "string" && /not authorized/i.test(msg)) {
484
+ return {
485
+ message: msg,
486
+ next:
487
+ "a cross-tenant read needs org-wide read authority, ownership of the target, or a " +
488
+ "per-tenant operator grant — ask your Token of Trust contact for one, or confirm the tenant name",
489
+ };
490
+ }
491
+ if (
492
+ typeof msg === "string" &&
493
+ /version-control app is bound|registered version-control app/i.test(msg)
494
+ ) {
495
+ return {
496
+ message: "your account isn't onboarded to read this tenant's content yet",
497
+ next: "re-run `tot login` to refresh your access, then retry — if it persists, ask your " +
498
+ "Token of Trust contact to finish onboarding your account",
499
+ };
500
+ }
501
+ return {
502
+ message: msg || "the read credential couldn't be minted",
503
+ next: "confirm the tenant name and that you're authorized to read it, then re-run",
504
+ };
505
+ }
506
+
244
507
  /**
245
508
  * Classify a `tenant_checkout` tool result: null when it's a genuine, usable
246
509
  * checkout (carries a gitRemote), otherwise a human { message, next } pair so
@@ -271,6 +534,22 @@ export function checkoutError(checkout) {
271
534
  next: "ask your Token of Trust contact to finish setting up your store, then re-run",
272
535
  };
273
536
  }
537
+ // Entitled, but not yet BOUND to the version-control app (appForCaller resolves
538
+ // nothing for this identity) — the 08-18 vc-app-binding gap. Distinct from
539
+ // not-provisioned: the STORE is fine; the developer just isn't onboarded to act
540
+ // THROUGH the app yet. The checkout preflight tries to self-heal this; if it still
541
+ // surfaces, the binding seam/backfill hasn't reached this identity — say so plainly
542
+ // instead of the generic "confirm you're entitled" (they ARE entitled).
543
+ if (
544
+ typeof msg === "string" &&
545
+ /version-control app is bound|registered version-control app/i.test(msg)
546
+ ) {
547
+ return {
548
+ message:
549
+ "your account isn't onboarded to check out this store yet (no version-control app is bound to your session)",
550
+ 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",
551
+ };
552
+ }
274
553
  return {
275
554
  message: msg || "the store checkout couldn't be completed",
276
555
  next: "confirm you're entitled to this store — `tot clone` (lists your stores)",