@tokenoftrust/cli 1.4.0-rc.19 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "1.4.0-rc.19",
3
+ "version": "1.4.0-rc.20",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -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";
@@ -43,6 +64,7 @@ function parseArgs(argv) {
43
64
  tenant: null,
44
65
  dir: null,
45
66
  tag: "main",
67
+ pr: null,
46
68
  remoteOnly: false,
47
69
  mcp: null,
48
70
  printRemote: false,
@@ -51,6 +73,7 @@ function parseArgs(argv) {
51
73
  for (let i = 0; i < argv.length; i++) {
52
74
  const t = argv[i];
53
75
  if (t === "--tag") a.tag = argv[++i];
76
+ else if (t === "--pr") a.pr = argv[++i];
54
77
  else if (t === "--remote-only") a.remoteOnly = true;
55
78
  else if (t === "--mcp") a.mcp = argv[++i];
56
79
  else if (t === "--print-remote") a.printRemote = true;
@@ -64,14 +87,36 @@ function parseArgs(argv) {
64
87
  return a;
65
88
  }
66
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
+
67
106
  const USAGE = `tot clone — clone a tenant store you can build on (mirrors \`git clone\`)
68
107
 
69
108
  tot clone list the stores you can build on
70
109
  tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
71
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>)
72
115
 
73
116
  Options:
74
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.
75
120
  --remote-only don't clone; just mint + print the clone URL for <tenant>.
76
121
  --mcp <url> MCP base URL. Default: env MCP_BASE_URL / TOT_MCP_URL, else
77
122
  ${DEFAULT_MCP_URL}.
@@ -105,10 +150,19 @@ export async function run(argv, ctx) {
105
150
  args.tenant = ctx.tenant;
106
151
  }
107
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
+
108
161
  // git-clone semantics: unless you explicitly asked for --remote-only, cloning
109
162
  // 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;
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);
112
166
 
113
167
  const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
114
168
  const redact = makeRedactor(env);
@@ -134,11 +188,48 @@ export async function run(argv, ctx) {
134
188
 
135
189
  // No tenant → list the stores this identity can build on and stop.
136
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
+ }
137
195
  const list = await client.callTool("client_list", {});
138
196
  printClientList(list);
139
197
  return 0;
140
198
  }
141
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
+
142
233
  console.error(`~ client_switch → ${args.tenant}`);
143
234
  console.error(
144
235
  `~ tenant_checkout (tag=${args.tag}) — MCP mints a fresh repo-scoped credential`,
@@ -257,6 +348,134 @@ async function cloneRepo(gitRemote, dir, redact) {
257
348
  return { dir, head };
258
349
  }
259
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
+
260
479
  /**
261
480
  * Classify a `tenant_checkout` tool result: null when it's a genuine, usable
262
481
  * checkout (carries a gitRemote), otherwise a human { message, next } pair so
@@ -695,8 +695,7 @@ function printCheckoutLanding(dir, cockpitUrl = null) {
695
695
 
696
696
  /** The crafted "you're live" ending — a PROMINENT milestone banner (u2) that,
697
697
  * when we hold a Developer Cockpit URL, explicitly sends the developer BACK to
698
- * their cockpit as the next place to look; then the AI-wow (G), seeded with
699
- * IDEAS[0] (G2/G3 — one prompt list shared with `tot ideas`, no drift). */
698
+ * their cockpit as the next place to look. */
700
699
  function printLiveEnding(tenant, url, elapsed, cockpitUrl = null) {
701
700
  const lines = [
702
701
  `✨ You're live.${elapsed ? ` (${elapsed})` : ""}`,
@@ -711,11 +710,6 @@ function printLiveEnding(tenant, url, elapsed, cockpitUrl = null) {
711
710
  console.log(milestoneBanner(lines));
712
711
  console.log(" " + versionStamp("native"));
713
712
  console.log("");
714
- console.log(" Now try, in Claude:");
715
- console.log(` "${IDEAS[0]}"`);
716
- console.log("");
717
- console.log(" More ideas: tot ideas");
718
- console.log("");
719
713
  }
720
714
 
721
715
  /**