@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 +1 -1
- package/src/commands/clone.mjs +221 -2
- package/src/commands/start.mjs +1 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.4.0-rc.
|
|
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",
|
package/src/commands/clone.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/commands/start.mjs
CHANGED
|
@@ -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
|
|
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
|
/**
|