@tokenoftrust/cli 1.4.0-rc.9 → 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.
- package/README.md +7 -4
- package/bin/tot.mjs +169 -8
- package/package.json +1 -1
- package/src/activity.mjs +378 -0
- package/src/candidate-state.mjs +56 -16
- package/src/commands/accept.mjs +725 -0
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/clone.mjs +289 -10
- package/src/commands/dev.mjs +479 -118
- package/src/commands/doctor.mjs +2 -1
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/pr.mjs +239 -15
- 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 +40 -8
- package/src/commands/submit.mjs +1325 -146
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +6 -1
- package/src/git-credential.mjs +184 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +27 -1
- package/src/validate.mjs +52 -0
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";
|
|
@@ -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
|
-
|
|
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}
|
|
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(` (
|
|
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
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* `
|
|
223
|
-
*
|
|
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(
|
|
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)",
|