@tokenoftrust/cli 1.4.0-rc.2 → 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/README.md +12 -9
- package/bin/tot.mjs +191 -14
- package/package.json +2 -2
- package/src/activity.mjs +378 -0
- package/src/candidate-state.mjs +137 -0
- package/src/commands/accept.mjs +313 -0
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/clone.mjs +682 -0
- package/src/commands/dev.mjs +414 -84
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/grants.mjs +8 -3
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +9 -4
- package/src/commands/pr.mjs +424 -0
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +80 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +401 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +35 -21
- package/src/commands/submit.mjs +1129 -129
- package/src/commands/sync.mjs +192 -0
- package/src/commands/validate.mjs +2 -2
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +14 -3
- package/src/obstacle-beacon.cjs +1 -1
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +27 -1
- package/src/commands/checkout.mjs +0 -330
|
@@ -0,0 +1,682 @@
|
|
|
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
|
+
|
|
58
|
+
const execFileP = promisify(execFile);
|
|
59
|
+
|
|
60
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
61
|
+
|
|
62
|
+
function parseArgs(argv) {
|
|
63
|
+
const a = {
|
|
64
|
+
tenant: null,
|
|
65
|
+
dir: null,
|
|
66
|
+
tag: "main",
|
|
67
|
+
pr: null,
|
|
68
|
+
remoteOnly: false,
|
|
69
|
+
mcp: null,
|
|
70
|
+
printRemote: false,
|
|
71
|
+
help: false,
|
|
72
|
+
};
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const t = argv[i];
|
|
75
|
+
if (t === "--tag") a.tag = argv[++i];
|
|
76
|
+
else if (t === "--pr") a.pr = argv[++i];
|
|
77
|
+
else if (t === "--remote-only") a.remoteOnly = true;
|
|
78
|
+
else if (t === "--mcp") a.mcp = argv[++i];
|
|
79
|
+
else if (t === "--print-remote") a.printRemote = true;
|
|
80
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
81
|
+
// Positional, git-clone style: `tot clone <tenant> [<dir>]`.
|
|
82
|
+
else if (!t.startsWith("--")) {
|
|
83
|
+
if (!a.tenant) a.tenant = t;
|
|
84
|
+
else if (!a.dir) a.dir = t;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return a;
|
|
88
|
+
}
|
|
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
|
+
|
|
106
|
+
const USAGE = `tot clone — clone a tenant store you can build on (mirrors \`git clone\`)
|
|
107
|
+
|
|
108
|
+
tot clone list the stores you can build on
|
|
109
|
+
tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
|
|
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>)
|
|
115
|
+
|
|
116
|
+
Options:
|
|
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.
|
|
120
|
+
--remote-only don't clone; just mint + print the clone URL for <tenant>.
|
|
121
|
+
--mcp <url> MCP base URL. Default: env MCP_BASE_URL / TOT_MCP_URL, else
|
|
122
|
+
${DEFAULT_MCP_URL}.
|
|
123
|
+
--print-remote with --remote-only, also print the authenticated remote
|
|
124
|
+
(contains a live token!).`;
|
|
125
|
+
|
|
126
|
+
/** Redact known secrets from any string before it hits the terminal. */
|
|
127
|
+
function makeRedactor(env) {
|
|
128
|
+
const secrets = [env.TOT_API_KEY, env.TOT_SECRET_KEY].filter(Boolean);
|
|
129
|
+
return (s) => {
|
|
130
|
+
let o = String(s);
|
|
131
|
+
for (const x of secrets) o = o.split(x).join("***");
|
|
132
|
+
return o;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {string[]} argv - args AFTER `checkout`
|
|
138
|
+
* @param {import("../context.mjs").detectContext extends (...a:any)=>infer R ? R : any} ctx
|
|
139
|
+
*/
|
|
140
|
+
export async function run(argv, ctx) {
|
|
141
|
+
const env = process.env;
|
|
142
|
+
const args = parseArgs(argv);
|
|
143
|
+
if (args.help) {
|
|
144
|
+
console.log(USAGE);
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Infer the tenant from a standalone checkout when run without one.
|
|
149
|
+
if (!args.tenant && ctx.mode === "checkout" && ctx.tenant) {
|
|
150
|
+
args.tenant = ctx.tenant;
|
|
151
|
+
}
|
|
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
|
+
|
|
161
|
+
// git-clone semantics: unless you explicitly asked for --remote-only, cloning
|
|
162
|
+
// is the default, and the target dir defaults to the tenant name (like
|
|
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);
|
|
166
|
+
|
|
167
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
168
|
+
const redact = makeRedactor(env);
|
|
169
|
+
const client = createMcpClient(baseUrl);
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
// Attach auth in the right order relative to the handshake (developer bearer
|
|
173
|
+
// BEFORE initialize) — see establishSession. When there's no session yet and
|
|
174
|
+
// we're on a TTY, offer to sign in inline and retry once, so a not-signed-in
|
|
175
|
+
// developer isn't dead-ended at "run tot login, then re-run".
|
|
176
|
+
try {
|
|
177
|
+
await establishSession(client, { env });
|
|
178
|
+
} catch (e) {
|
|
179
|
+
if (e instanceof AuthUnavailableError && e.reason === "missing") {
|
|
180
|
+
const signedIn = await offerSignIn(client.mcpUrl, env, {});
|
|
181
|
+
if (!signedIn) throw e; // declined/non-TTY → the crisp error below
|
|
182
|
+
await establishSession(client, { env }); // retry once, in-flow
|
|
183
|
+
} else {
|
|
184
|
+
throw e;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
console.error(`~ signed in → ${client.mcpUrl}`);
|
|
188
|
+
|
|
189
|
+
// No tenant → list the stores this identity can build on and stop.
|
|
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
|
+
}
|
|
195
|
+
const list = await client.callTool("client_list", {});
|
|
196
|
+
printClientList(list);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
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
|
+
|
|
233
|
+
console.error(`~ client_switch → ${args.tenant}`);
|
|
234
|
+
console.error(
|
|
235
|
+
`~ tenant_checkout (tag=${args.tag}) — MCP mints a fresh repo-scoped credential`,
|
|
236
|
+
);
|
|
237
|
+
const res = await checkoutTenant(client, {
|
|
238
|
+
tenant: args.tenant,
|
|
239
|
+
tag: args.tag,
|
|
240
|
+
cloneDir,
|
|
241
|
+
redact,
|
|
242
|
+
});
|
|
243
|
+
console.log(`\n+ ready. repo: ${res.cloneUrl || res.publicUrl}`);
|
|
244
|
+
|
|
245
|
+
if (res.cloned) {
|
|
246
|
+
console.log(`+ cloned. HEAD: ${res.head}`);
|
|
247
|
+
console.log(`\nYour local working clone is at ${res.dir} with an authenticated remote.`);
|
|
248
|
+
console.log(` cd ${res.dir} && tot dev # run it locally with save→reload`);
|
|
249
|
+
console.log(` (the minted token lives in .git/config; a later clone rotates it)`);
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log(`\nPublic clone URL (no credential): ${res.publicUrl}`);
|
|
254
|
+
console.log(`Drop --remote-only to clone with the authenticated remote configured.`);
|
|
255
|
+
if (args.printRemote) {
|
|
256
|
+
console.log(
|
|
257
|
+
`\nAUTHENTICATED remote (contains a live token — handle carefully):\n${res.gitRemote}`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return 0;
|
|
261
|
+
} catch (e) {
|
|
262
|
+
if (e instanceof AuthUnavailableError) {
|
|
263
|
+
console.error(formatError(e));
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
if (e instanceof CliError) {
|
|
267
|
+
console.error(formatError(e));
|
|
268
|
+
return e.exitCode ?? 1;
|
|
269
|
+
}
|
|
270
|
+
console.error(fail(`checkout failed: ${redact(String(e?.message || e))}`));
|
|
271
|
+
return 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The privileged checkout core, composable in-process (used by `tot clone`
|
|
277
|
+
* and by `tot start`): client_switch → tenant_checkout → optionally clone. The
|
|
278
|
+
* MCP mints a fresh, repo-scoped push credential each time. Assumes `client` is
|
|
279
|
+
* already initialized + has a validated session.
|
|
280
|
+
*
|
|
281
|
+
* @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
|
|
282
|
+
* @param {{ tenant: string, tag?: string, cloneDir?: string|null, redact?: (s:string)=>string }} opts
|
|
283
|
+
* @returns {Promise<{ gitRemote: string, cloneUrl: string|null, publicUrl: string,
|
|
284
|
+
* cloned: boolean, dir: string|null, head: string|null }>}
|
|
285
|
+
*/
|
|
286
|
+
export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
|
|
287
|
+
await client.callTool("client_switch", { tenant });
|
|
288
|
+
// vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
|
|
289
|
+
// storefront version-control app BEFORE checkout, so tenant_checkout can resolve
|
|
290
|
+
// `appForCaller` for them instead of 403-ing "No version-control app is bound." The
|
|
291
|
+
// human's OWN session self-triggers it (no subject_token — the subject is the
|
|
292
|
+
// caller's already-verified identity); the MCP entitlement-gates + authors the bind.
|
|
293
|
+
// Best-effort + FAIL-OPEN: a missing tool / cold MCP / any fault must never block a
|
|
294
|
+
// checkout that would otherwise succeed, and if the developer is still unbound the
|
|
295
|
+
// tenant_checkout error below (via checkoutError) is the honest backstop. Binding is
|
|
296
|
+
// also ensured at the authority seams (native OAuth sign-in) + backfill; this line
|
|
297
|
+
// self-heals a legacy session that predates them.
|
|
298
|
+
try {
|
|
299
|
+
// Wire name is `identity_bind` (tot-mcp taxonomy: <subject>_<action>).
|
|
300
|
+
await client.callTool("identity_bind", {});
|
|
301
|
+
} catch {
|
|
302
|
+
/* fail-open — see above */
|
|
303
|
+
}
|
|
304
|
+
const checkout = await client.callTool("tenant_checkout", { tenant, tag });
|
|
305
|
+
|
|
306
|
+
// A non-checkout result (not provisioned / not entitled / failed) must surface
|
|
307
|
+
// the MCP's human message + a concrete next step, NEVER a raw JSON.stringify
|
|
308
|
+
// dump (James's 2026-07-19 first-experience failure).
|
|
309
|
+
const err = checkoutError(checkout);
|
|
310
|
+
if (err) {
|
|
311
|
+
throw new CliError(redact(err.message), { next: err.next });
|
|
312
|
+
}
|
|
313
|
+
const gitRemote = checkout.gitRemote;
|
|
314
|
+
const cloneUrl = checkout.cloneUrl ?? null;
|
|
315
|
+
const u = new URL(gitRemote);
|
|
316
|
+
const publicUrl = `${u.protocol}//${u.host}${u.pathname}`;
|
|
317
|
+
|
|
318
|
+
if (!cloneDir) {
|
|
319
|
+
return { gitRemote, cloneUrl, publicUrl, cloned: false, dir: null, head: null };
|
|
320
|
+
}
|
|
321
|
+
const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact);
|
|
322
|
+
return { gitRemote, cloneUrl, publicUrl, cloned: true, dir, head };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* git clone the authenticated remote into `dir`. Throws CliError on failure.
|
|
327
|
+
* Runs git as a NON-BLOCKING child process (promisified execFile) so the clone
|
|
328
|
+
* doesn't stall the Node event loop — `tot start` runs this inside a
|
|
329
|
+
* `Promise.all([...])` alongside the renderer prefetch, and a synchronous clone
|
|
330
|
+
* would serialize what's meant to overlap.
|
|
331
|
+
*/
|
|
332
|
+
async function cloneRepo(gitRemote, dir, redact) {
|
|
333
|
+
const git = async (cargs) => (await execFileP("git", cargs)).stdout.toString();
|
|
334
|
+
console.log(`+ git clone → ${dir}`);
|
|
335
|
+
try {
|
|
336
|
+
await git(["clone", gitRemote, dir]);
|
|
337
|
+
} catch (e) {
|
|
338
|
+
// Beacon the cockpit before we surface the error — covers this path for both
|
|
339
|
+
// `tot clone` and `tot start` (which clones through here). Awaited so the
|
|
340
|
+
// packet lands before the process prints + exits; swallowed either way.
|
|
341
|
+
await emitObstacle("clone-failed");
|
|
342
|
+
throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
|
|
343
|
+
next: `check the target dir is empty and you can reach the remote, then re-run`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
|
|
347
|
+
writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
|
|
348
|
+
return { dir, head };
|
|
349
|
+
}
|
|
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
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Classify a `tenant_checkout` tool result: null when it's a genuine, usable
|
|
481
|
+
* checkout (carries a gitRemote), otherwise a human { message, next } pair so
|
|
482
|
+
* callers surface the MCP's own words + a concrete next step instead of dumping
|
|
483
|
+
* raw JSON (James's 2026-07-19 first-experience failure: a not-provisioned store
|
|
484
|
+
* printed JSON.stringify(checkout)). callTool unwraps the tool result to its
|
|
485
|
+
* structuredContent / parsed text, so a failure surfaces as an error-ish status
|
|
486
|
+
* ('forbidden' | 'invalid_input' | 'checkout_failed' | 'error'), a `message`, or
|
|
487
|
+
* simply a missing gitRemote. When the repo isn't provisioned yet we speak to the
|
|
488
|
+
* INVITED DEVELOPER ("your store isn't set up yet"), not the operator — dropping
|
|
489
|
+
* the `repo_provision` jargon the MCP aims at whoever provisions. Any other
|
|
490
|
+
* failure surfaces the MCP's own message. Pure + exported so it's unit-tested
|
|
491
|
+
* without any I/O.
|
|
492
|
+
* @param {unknown} checkout
|
|
493
|
+
* @returns {{ message: string, next: string }|null}
|
|
494
|
+
*/
|
|
495
|
+
export function checkoutError(checkout) {
|
|
496
|
+
const c = checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null;
|
|
497
|
+
if (c && c.gitRemote) return null; // a usable checkout — never an error
|
|
498
|
+
const msg =
|
|
499
|
+
(c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
|
|
500
|
+
(c && typeof c.raw === "string" && c.raw.trim() ? c.raw.trim() : null) ||
|
|
501
|
+
null;
|
|
502
|
+
// The store's repo isn't provisioned yet — it isn't set up for this developer.
|
|
503
|
+
if (typeof msg === "string" && /not provisioned/i.test(msg)) {
|
|
504
|
+
return {
|
|
505
|
+
message: "your store isn't set up on Token of Trust yet",
|
|
506
|
+
next: "ask your Token of Trust contact to finish setting up your store, then re-run",
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
// Entitled, but not yet BOUND to the version-control app (appForCaller resolves
|
|
510
|
+
// nothing for this identity) — the 08-18 vc-app-binding gap. Distinct from
|
|
511
|
+
// not-provisioned: the STORE is fine; the developer just isn't onboarded to act
|
|
512
|
+
// THROUGH the app yet. The checkout preflight tries to self-heal this; if it still
|
|
513
|
+
// surfaces, the binding seam/backfill hasn't reached this identity — say so plainly
|
|
514
|
+
// instead of the generic "confirm you're entitled" (they ARE entitled).
|
|
515
|
+
if (
|
|
516
|
+
typeof msg === "string" &&
|
|
517
|
+
/version-control app is bound|registered version-control app/i.test(msg)
|
|
518
|
+
) {
|
|
519
|
+
return {
|
|
520
|
+
message:
|
|
521
|
+
"your account isn't onboarded to check out this store yet (no version-control app is bound to your session)",
|
|
522
|
+
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",
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
message: msg || "the store checkout couldn't be completed",
|
|
527
|
+
next: "confirm you're entitled to this store — `tot clone` (lists your stores)",
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Normalize the (shape-varying) `client_list` response into a plain, sorted
|
|
533
|
+
* list of the stores this identity can build on. Shared by `tot clone`'s
|
|
534
|
+
* listing and `tot start`'s auto-pick so both read the same fields.
|
|
535
|
+
* @param {unknown} list
|
|
536
|
+
* @returns {Array<{ id: string, name: string, raw: any }>}
|
|
537
|
+
*/
|
|
538
|
+
export function normalizeStores(list) {
|
|
539
|
+
const rows = Array.isArray(list) ? list : list?.clients || list?.tenants || [];
|
|
540
|
+
if (!Array.isArray(rows)) return [];
|
|
541
|
+
return rows
|
|
542
|
+
.map((r) => ({
|
|
543
|
+
id: r?.tenant || r?.id || r?.clientId || r?.appDomain || null,
|
|
544
|
+
name: r?.displayName || r?.name || "",
|
|
545
|
+
raw: r,
|
|
546
|
+
}))
|
|
547
|
+
.filter((r) => r.id);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Detect whether a `client_list` tool result is an ERROR result rather than a
|
|
552
|
+
* genuinely empty-but-successful store list — so callers surface it (identity +
|
|
553
|
+
* origin + reason, per fb-1783905718950-f45zg9) instead of collapsing it to an
|
|
554
|
+
* empty list and dead-ending at "ask for an invite". callTool unwraps a tool
|
|
555
|
+
* result to its structuredContent / parsed text, so an error surfaces as a
|
|
556
|
+
* status other than ok/success, an `error`/`isError` field, or an unparseable
|
|
557
|
+
* `raw` text blob. A response that carries a resolvable store array (even empty)
|
|
558
|
+
* is always a success. Returns a short human reason, or null when it's not an
|
|
559
|
+
* error. Pure + exported so it's unit-tested without any I/O.
|
|
560
|
+
* @param {unknown} list
|
|
561
|
+
* @returns {string|null}
|
|
562
|
+
*/
|
|
563
|
+
export function storeListError(list) {
|
|
564
|
+
if (list == null || typeof list !== "object" || Array.isArray(list)) return null;
|
|
565
|
+
// A resolvable store array present → it succeeded, never an error.
|
|
566
|
+
if (Array.isArray(list.clients) || Array.isArray(list.tenants)) return null;
|
|
567
|
+
const msg =
|
|
568
|
+
list.message ||
|
|
569
|
+
(typeof list.error === "string" ? list.error : list.error?.message) ||
|
|
570
|
+
null;
|
|
571
|
+
if (list.isError) return msg || "the store list request returned an error";
|
|
572
|
+
if (typeof list.status === "string" && !/^(ok|success)$/i.test(list.status)) {
|
|
573
|
+
return msg || `the store list request returned status "${list.status}"`;
|
|
574
|
+
}
|
|
575
|
+
if (list.error) return msg || "the store list request returned an error";
|
|
576
|
+
if (typeof list.raw === "string" && list.raw.trim()) return list.raw.trim();
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Extract card c1's broker-identity remediation signal from a `client_list`
|
|
582
|
+
* result. When the identity resolved ZERO stores, the server MAY carry:
|
|
583
|
+
* - `brokerStatus`: 'unlinked' | 'unconfigured' | 'broker_error' — present only
|
|
584
|
+
* when the empty scope is a BROKER-IDENTITY problem (not an entitlement one);
|
|
585
|
+
* ABSENT for the genuine linked-but-zero-grants case.
|
|
586
|
+
* - `nextAction`: a status-specific human remediation string the server owns
|
|
587
|
+
* (single source of truth; e.g. an unlinked identity is told to finish
|
|
588
|
+
* linking, an operator sees a diagnostic, zero-grants sees NO_TENANTS_MESSAGE).
|
|
589
|
+
* Returns { brokerStatus, nextAction } with nulls when absent. Pure + exported so
|
|
590
|
+
* it's unit-tested without any I/O.
|
|
591
|
+
* @param {unknown} list
|
|
592
|
+
* @returns {{ brokerStatus: string|null, nextAction: string|null }}
|
|
593
|
+
*/
|
|
594
|
+
export function brokerRemediation(list) {
|
|
595
|
+
const c = list && typeof list === "object" && !Array.isArray(list) ? list : null;
|
|
596
|
+
const brokerStatus = c && typeof c.brokerStatus === "string" ? c.brokerStatus : null;
|
|
597
|
+
const nextAction =
|
|
598
|
+
c && typeof c.nextAction === "string" && c.nextAction.trim() ? c.nextAction.trim() : null;
|
|
599
|
+
return { brokerStatus, nextAction };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Status-aware human guidance for an authenticated identity that resolved ZERO
|
|
604
|
+
* stores (card c2 — the fix for the misleading "may still be propagating / ask for
|
|
605
|
+
* a store invite" copy that dead-ended an UNLINKED identity). Driven by card c1's
|
|
606
|
+
* `brokerStatus`/`nextAction`:
|
|
607
|
+
* - `unlinked` → the identity isn't linked to the ToT broker yet, so no scope can
|
|
608
|
+
* resolve. This is NOT an entitlement problem — point at `tot link` (the
|
|
609
|
+
* terminal action), NOT "ask for a store invite".
|
|
610
|
+
* - `unconfigured` | `broker_error` → an operator/diagnostic condition; surface
|
|
611
|
+
* the server's own `nextAction`, never "ask for an invite".
|
|
612
|
+
* - no brokerStatus → the genuine linked-but-zero-grants case; keep the existing
|
|
613
|
+
* "invite may still be propagating / ask for one" wording (NO_TENANTS_MESSAGE),
|
|
614
|
+
* preferring the server's `nextAction` when present.
|
|
615
|
+
*
|
|
616
|
+
* Prefers the server's `nextAction` as the concrete `next` step (single source of
|
|
617
|
+
* truth) and falls back to sensible local copy when it's absent (older server).
|
|
618
|
+
* `linkHint` names the terminal action an unlinked identity is pointed at — the
|
|
619
|
+
* built-in `tot link`; a caller that didn't build `tot link` passes an MCP-client
|
|
620
|
+
* phrasing instead. Pure + exported so it's unit-tested without any I/O.
|
|
621
|
+
* @param {unknown} list
|
|
622
|
+
* @param {{ linkHint?: string }} [opts]
|
|
623
|
+
* @returns {{ brokerStatus: string|null, headline: string, next: string }}
|
|
624
|
+
*/
|
|
625
|
+
export function noStoresGuidance(list, { linkHint = "tot link" } = {}) {
|
|
626
|
+
const { brokerStatus, nextAction } = brokerRemediation(list);
|
|
627
|
+
|
|
628
|
+
if (brokerStatus === "unlinked") {
|
|
629
|
+
// The actionable terminal step beats the server's identity_link_begin/poll
|
|
630
|
+
// MCP-tool wording for a CLI user, so lead with `tot link` (c2 optional-arm
|
|
631
|
+
// directive). Fall back to the server string only if there's no link action.
|
|
632
|
+
return {
|
|
633
|
+
brokerStatus,
|
|
634
|
+
headline: "your Token of Trust identity isn't linked yet, so no stores could be resolved",
|
|
635
|
+
next: linkHint
|
|
636
|
+
? `run \`${linkHint}\` to finish linking your identity, then re-run`
|
|
637
|
+
: nextAction || "finish linking your identity (identity_link_begin), then re-run",
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
if (brokerStatus === "unconfigured" || brokerStatus === "broker_error") {
|
|
641
|
+
// Operator/diagnostic conditions — surface the server's own words; do NOT tell
|
|
642
|
+
// the developer to "ask for an invite" (it isn't an entitlement problem).
|
|
643
|
+
return {
|
|
644
|
+
brokerStatus,
|
|
645
|
+
headline: "no stores could be resolved — the Token of Trust identity broker had a problem",
|
|
646
|
+
next: nextAction || "run `tot whoami` for details, or contact your Token of Trust operator",
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
// No brokerStatus → the genuine linked-but-zero-grants case.
|
|
650
|
+
return {
|
|
651
|
+
brokerStatus: null,
|
|
652
|
+
headline: "you have no stores to build on yet",
|
|
653
|
+
next:
|
|
654
|
+
nextAction ||
|
|
655
|
+
"if you were just invited, it may still be propagating — try again in a minute; " +
|
|
656
|
+
"otherwise ask your Token of Trust contact for a store invite (see `tot whoami`)",
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function printClientList(list) {
|
|
661
|
+
const err = storeListError(list);
|
|
662
|
+
const stores = normalizeStores(list);
|
|
663
|
+
if (err && stores.length === 0) {
|
|
664
|
+
console.log(`\nCouldn't list your stores: ${err}`);
|
|
665
|
+
console.log("Run `tot whoami` to check your session, or `tot login` again.");
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (stores.length === 0) {
|
|
669
|
+
// Status-aware, human-readable guidance — NEVER a raw JSON dump (the old
|
|
670
|
+
// behaviour, James's 2026-07-19 failure mode).
|
|
671
|
+
const g = noStoresGuidance(list);
|
|
672
|
+
const line = g.headline.charAt(0).toUpperCase() + g.headline.slice(1);
|
|
673
|
+
console.log(`\n${line}.`);
|
|
674
|
+
console.log(`Next: ${g.next}`);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
console.log("\nStores you can build on:\n");
|
|
678
|
+
for (const s of stores) {
|
|
679
|
+
console.log(` ${s.id}${s.name ? ` — ${s.name}` : ""}`);
|
|
680
|
+
}
|
|
681
|
+
console.log(`\nNext: tot clone <tenant>`);
|
|
682
|
+
}
|