@tokenoftrust/cli 1.4.0 → 1.5.0
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 +5 -0
- package/bin/tot.mjs +148 -57
- package/package.json +6 -1
- package/src/activity.mjs +379 -0
- package/src/app-scaffold.mjs +4 -4
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +3 -3
- package/src/commands/accept.mjs +498 -59
- package/src/commands/app/dev.mjs +8 -4
- package/src/commands/app/index.mjs +3 -3
- package/src/commands/app/scaffold.mjs +1 -1
- package/src/commands/branches.mjs +297 -0
- package/src/commands/cleanup.mjs +264 -0
- package/src/commands/clone.mjs +307 -25
- package/src/commands/dev.mjs +440 -156
- package/src/commands/doctor.mjs +4 -4
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +9 -5
- package/src/commands/grants.mjs +7 -5
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/ideas.mjs +2 -2
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +5 -6
- package/src/commands/pr.mjs +62 -25
- package/src/commands/preview-build.mjs +6 -6
- package/src/commands/preview-doctor.mjs +225 -0
- package/src/commands/preview-retry-evidence.mjs +156 -0
- package/src/commands/preview.mjs +19 -3
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +18 -16
- package/src/commands/ship.mjs +51 -14
- package/src/commands/start.mjs +101 -59
- package/src/commands/submit.mjs +1183 -169
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +10 -4
- package/src/commands/whoami.mjs +1 -1
- package/src/dev-heartbeat.mjs +3 -2
- package/src/dev-logs.mjs +2 -2
- package/src/errors.mjs +11 -4
- package/src/git-credential.mjs +257 -0
- package/src/last-tenant.mjs +1 -1
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +18 -14
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +83 -15
- package/src/sample.mjs +4 -4
- package/src/validate.mjs +187 -15
- package/src/vendor/private-apps-devkit.mjs +3 -3
- package/src/viewer-session.mjs +118 -0
- package/template/private-app/README.md +12 -6
- package/src/commands/retire.mjs +0 -203
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. `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,16 +54,19 @@ 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
|
|
|
39
61
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
40
62
|
|
|
41
63
|
function parseArgs(argv) {
|
|
64
|
+
/** @type {{ tenant: string|null, dir: string|null, tag: string, pr: string|null, remoteOnly: boolean, mcp: string|null, printRemote: boolean, help: boolean }} */
|
|
42
65
|
const a = {
|
|
43
66
|
tenant: null,
|
|
44
67
|
dir: null,
|
|
45
68
|
tag: "main",
|
|
69
|
+
pr: null,
|
|
46
70
|
remoteOnly: false,
|
|
47
71
|
mcp: null,
|
|
48
72
|
printRemote: false,
|
|
@@ -51,6 +75,7 @@ function parseArgs(argv) {
|
|
|
51
75
|
for (let i = 0; i < argv.length; i++) {
|
|
52
76
|
const t = argv[i];
|
|
53
77
|
if (t === "--tag") a.tag = argv[++i];
|
|
78
|
+
else if (t === "--pr") a.pr = argv[++i];
|
|
54
79
|
else if (t === "--remote-only") a.remoteOnly = true;
|
|
55
80
|
else if (t === "--mcp") a.mcp = argv[++i];
|
|
56
81
|
else if (t === "--print-remote") a.printRemote = true;
|
|
@@ -64,14 +89,36 @@ function parseArgs(argv) {
|
|
|
64
89
|
return a;
|
|
65
90
|
}
|
|
66
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Parse + validate the `--pr` value into a positive integer, or null when
|
|
94
|
+
* absent. Throws a CliError (never a raw NaN downstream) on garbage input.
|
|
95
|
+
* Pure. @param {unknown} raw @returns {number|null}
|
|
96
|
+
*/
|
|
97
|
+
export function parsePrNumber(raw) {
|
|
98
|
+
if (raw == null) return null;
|
|
99
|
+
const n = Number(raw);
|
|
100
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
101
|
+
throw new CliError(`invalid --pr value "${raw}" — must be a positive PR number`, {
|
|
102
|
+
next: "tot clone <tenant> --pr <N> [dir]",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return n;
|
|
106
|
+
}
|
|
107
|
+
|
|
67
108
|
const USAGE = `tot clone — clone a tenant store you can build on (mirrors \`git clone\`)
|
|
68
109
|
|
|
69
110
|
tot clone list the stores you can build on
|
|
70
111
|
tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
|
|
71
112
|
tot clone <tenant> <dir> clone into <dir>
|
|
113
|
+
tot clone <tenant> --pr <N> [dir] OPERATOR: clone PR #<N>'s head, READ-ONLY
|
|
114
|
+
(works even on a tenant you don't own/aren't a
|
|
115
|
+
member of, when authorized; dir defaults to
|
|
116
|
+
<tenant>-pr<N>)
|
|
72
117
|
|
|
73
118
|
Options:
|
|
74
119
|
--tag <tag> which repo (repo = "<tenant>-<tag>"). Default: main.
|
|
120
|
+
--pr <N> clone PR #<N>'s head instead of the default branch — a
|
|
121
|
+
READ-ONLY cross-tenant credential, not a push checkout.
|
|
75
122
|
--remote-only don't clone; just mint + print the clone URL for <tenant>.
|
|
76
123
|
--mcp <url> MCP base URL. Default: env MCP_BASE_URL / TOT_MCP_URL, else
|
|
77
124
|
${DEFAULT_MCP_URL}.
|
|
@@ -105,10 +152,19 @@ export async function run(argv, ctx) {
|
|
|
105
152
|
args.tenant = ctx.tenant;
|
|
106
153
|
}
|
|
107
154
|
|
|
155
|
+
let pr;
|
|
156
|
+
try {
|
|
157
|
+
pr = parsePrNumber(args.pr);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
console.error(formatError(e));
|
|
160
|
+
return e.exitCode ?? 1;
|
|
161
|
+
}
|
|
162
|
+
|
|
108
163
|
// git-clone semantics: unless you explicitly asked for --remote-only, cloning
|
|
109
164
|
// 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
|
-
|
|
165
|
+
// `git clone <url>` deriving the dir from the repo basename) — or, with
|
|
166
|
+
// --pr, to "<tenant>-pr<N>" so a PR clone never collides with a plain one.
|
|
167
|
+
const cloneDir = args.remoteOnly ? null : args.dir || (pr ? `${args.tenant}-pr${pr}` : args.tenant);
|
|
112
168
|
|
|
113
169
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
114
170
|
const redact = makeRedactor(env);
|
|
@@ -134,11 +190,48 @@ export async function run(argv, ctx) {
|
|
|
134
190
|
|
|
135
191
|
// No tenant → list the stores this identity can build on and stop.
|
|
136
192
|
if (!args.tenant) {
|
|
193
|
+
if (pr) {
|
|
194
|
+
console.error(fail("`tot clone --pr` needs a tenant", "tot clone <tenant> --pr <N> [dir]"));
|
|
195
|
+
return 2;
|
|
196
|
+
}
|
|
137
197
|
const list = await client.callTool("client_list", {});
|
|
138
198
|
printClientList(list);
|
|
139
199
|
return 0;
|
|
140
200
|
}
|
|
141
201
|
|
|
202
|
+
// --pr ALWAYS takes the cross-tenant read-credential path, even for your own
|
|
203
|
+
// tenant — tenant_checkout has no PR-ref parameter to select, so there is no
|
|
204
|
+
// membership-based fork here (see the file header comment).
|
|
205
|
+
if (pr) {
|
|
206
|
+
console.error(
|
|
207
|
+
`~ repo_read_credential (tenant=${args.tenant}) — MCP mints a read-only credential`,
|
|
208
|
+
);
|
|
209
|
+
const res = await cloneCrossTenantPr(client, {
|
|
210
|
+
tenant: args.tenant,
|
|
211
|
+
pr,
|
|
212
|
+
cloneDir,
|
|
213
|
+
redact,
|
|
214
|
+
});
|
|
215
|
+
console.log(`\n+ ready (read-only). repo: ${res.publicUrl}`);
|
|
216
|
+
|
|
217
|
+
if (res.cloned) {
|
|
218
|
+
console.log(`+ cloned PR #${pr}. HEAD: ${res.head}`);
|
|
219
|
+
console.log(`\nYour local read-only clone of PR #${pr} is at ${res.dir}.`);
|
|
220
|
+
console.log(` (this credential can only pull, never push — for your own tenant's`);
|
|
221
|
+
console.log(` push checkout, run \`tot clone ${args.tenant}\` without --pr)`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
console.log(`\nPublic clone URL (no credential): ${res.publicUrl}`);
|
|
226
|
+
console.log(`Drop --remote-only to clone PR #${pr} with the read-only remote configured.`);
|
|
227
|
+
if (args.printRemote) {
|
|
228
|
+
console.log(
|
|
229
|
+
`\nREAD-ONLY remote (contains a live token — handle carefully):\n${res.gitRemote}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
142
235
|
console.error(`~ client_switch → ${args.tenant}`);
|
|
143
236
|
console.error(
|
|
144
237
|
`~ tenant_checkout (tag=${args.tag}) — MCP mints a fresh repo-scoped credential`,
|
|
@@ -153,9 +246,9 @@ export async function run(argv, ctx) {
|
|
|
153
246
|
|
|
154
247
|
if (res.cloned) {
|
|
155
248
|
console.log(`+ cloned. HEAD: ${res.head}`);
|
|
156
|
-
console.log(`\nYour local working clone is at ${res.dir}
|
|
249
|
+
console.log(`\nYour local working clone is at ${res.dir}.`);
|
|
157
250
|
console.log(` cd ${res.dir} && tot dev # run it locally with save→reload`);
|
|
158
|
-
console.log(` (
|
|
251
|
+
console.log(` (origin has no token — \`tot\` mints one fresh at fetch/push time)`);
|
|
159
252
|
return 0;
|
|
160
253
|
}
|
|
161
254
|
|
|
@@ -194,6 +287,22 @@ export async function run(argv, ctx) {
|
|
|
194
287
|
*/
|
|
195
288
|
export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
|
|
196
289
|
await client.callTool("client_switch", { tenant });
|
|
290
|
+
// vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
|
|
291
|
+
// storefront version-control app BEFORE checkout, so tenant_checkout can resolve
|
|
292
|
+
// `appForCaller` for them instead of 403-ing "No version-control app is bound." The
|
|
293
|
+
// human's OWN session self-triggers it (no subject_token — the subject is the
|
|
294
|
+
// caller's already-verified identity); the MCP entitlement-gates + authors the bind.
|
|
295
|
+
// Best-effort + FAIL-OPEN: a missing tool / cold MCP / any fault must never block a
|
|
296
|
+
// checkout that would otherwise succeed, and if the developer is still unbound the
|
|
297
|
+
// tenant_checkout error below (via checkoutError) is the honest backstop. Binding is
|
|
298
|
+
// also ensured at the authority seams (native OAuth sign-in) + backfill; this line
|
|
299
|
+
// self-heals a legacy session that predates them.
|
|
300
|
+
try {
|
|
301
|
+
// Wire name is `identity_bind` (tot-mcp taxonomy: <subject>_<action>).
|
|
302
|
+
await client.callTool("identity_bind", {});
|
|
303
|
+
} catch {
|
|
304
|
+
/* fail-open — see above */
|
|
305
|
+
}
|
|
197
306
|
const checkout = await client.callTool("tenant_checkout", { tenant, tag });
|
|
198
307
|
|
|
199
308
|
// A non-checkout result (not provisioned / not entitled / failed) must surface
|
|
@@ -216,17 +325,38 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
|
|
|
216
325
|
}
|
|
217
326
|
|
|
218
327
|
/**
|
|
219
|
-
* git clone the authenticated remote into `dir
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* `
|
|
223
|
-
*
|
|
328
|
+
* git clone the authenticated remote into `dir` — WITHOUT ever writing the
|
|
329
|
+
* live token to `.git/config`. The MCP mints `gitRemote` as a
|
|
330
|
+
* basic-auth URL (`user:token@host`); rather than passing that straight to
|
|
331
|
+
* `git clone` (which records exactly the URL it was given as `origin`, token
|
|
332
|
+
* and all), the token is handed to git
|
|
333
|
+
* EPHEMERALLY via a one-shot `http.extraheader` (same mechanism
|
|
334
|
+
* pushPreviewRef in submit.mjs uses for a push) while the clone SOURCE is
|
|
335
|
+
* already the tokenless public URL — so `origin` comes out tokenless from
|
|
336
|
+
* the very first commit. The new checkout's `credential.helper` is then
|
|
337
|
+
* configured to `tot git-credential`, so every later fetch/push mints a
|
|
338
|
+
* fresh token through the CLI's own login session instead of ever needing
|
|
339
|
+
* one persisted on disk (a later clone no longer needs to "rotate" anything
|
|
340
|
+
* — the old flow's `.git/config` token is simply never written).
|
|
341
|
+
*
|
|
342
|
+
* Runs git as a NON-BLOCKING child process (promisified execFile) so the
|
|
343
|
+
* clone doesn't stall the Node event loop — `tot start` runs this inside a
|
|
344
|
+
* `Promise.all([...])` alongside the renderer prefetch, and a synchronous
|
|
345
|
+
* clone would serialize what's meant to overlap. Throws CliError on failure.
|
|
224
346
|
*/
|
|
225
347
|
async function cloneRepo(gitRemote, dir, redact) {
|
|
226
348
|
const git = async (cargs) => (await execFileP("git", cargs)).stdout.toString();
|
|
227
349
|
console.log(`+ git clone → ${dir}`);
|
|
350
|
+
const cred = splitAuthedRemote(gitRemote);
|
|
351
|
+
const cloneArgs = cred
|
|
352
|
+
? [
|
|
353
|
+
"-c", `http.extraheader=${basicAuthExtraHeader(cred.username, cred.token)}`,
|
|
354
|
+
"-c", "credential.helper=",
|
|
355
|
+
"clone", cred.publicUrl, dir,
|
|
356
|
+
]
|
|
357
|
+
: ["clone", gitRemote, dir]; // no embedded token (e.g. already public) — clone as given
|
|
228
358
|
try {
|
|
229
|
-
await git(
|
|
359
|
+
await git(cloneArgs);
|
|
230
360
|
} catch (e) {
|
|
231
361
|
// Beacon the cockpit before we surface the error — covers this path for both
|
|
232
362
|
// `tot clone` and `tot start` (which clones through here). Awaited so the
|
|
@@ -236,11 +366,145 @@ async function cloneRepo(gitRemote, dir, redact) {
|
|
|
236
366
|
next: `check the target dir is empty and you can reach the remote, then re-run`,
|
|
237
367
|
});
|
|
238
368
|
}
|
|
369
|
+
try {
|
|
370
|
+
await git(["-C", dir, "config", "--local", "credential.helper", CREDENTIAL_HELPER]);
|
|
371
|
+
} catch {
|
|
372
|
+
// Best-effort — a checkout missing the helper still WORKS (the token just
|
|
373
|
+
// isn't self-healing yet); the next `tot preview`/`tot sync` migrates it.
|
|
374
|
+
}
|
|
239
375
|
const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
|
|
240
376
|
writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
|
|
241
377
|
return { dir, head };
|
|
242
378
|
}
|
|
243
379
|
|
|
380
|
+
/**
|
|
381
|
+
* The cross-tenant PR-clone core, composable in-process exactly like
|
|
382
|
+
* `checkoutTenant`: mint a READ-ONLY credential via `repo_read_credential`, then
|
|
383
|
+
* (optionally) clone it and materialize PR #<pr>'s head. This is the path `--pr`
|
|
384
|
+
* ALWAYS takes — own tenant or foreign — because tenant_checkout has no PR-ref
|
|
385
|
+
* parameter (see the file header). Assumes `client` is already initialized +
|
|
386
|
+
* has a validated session.
|
|
387
|
+
*
|
|
388
|
+
* @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
|
|
389
|
+
* @param {{ tenant: string, pr: number, cloneDir?: string|null, redact?: (s:string)=>string }} opts
|
|
390
|
+
* @returns {Promise<{ gitRemote: string, publicUrl: string, cloned: boolean,
|
|
391
|
+
* dir: string|null, head: string|null }>}
|
|
392
|
+
*/
|
|
393
|
+
export async function cloneCrossTenantPr(
|
|
394
|
+
client,
|
|
395
|
+
{ tenant, pr, cloneDir = null, redact = (s) => s },
|
|
396
|
+
) {
|
|
397
|
+
const minted = await client.callTool("repo_read_credential", { tenant });
|
|
398
|
+
|
|
399
|
+
// A non-mint result (unauthenticated / forbidden / invalid_input / error) must
|
|
400
|
+
// surface the MCP's OWN denial reason + a concrete next step — never a raw
|
|
401
|
+
// JSON dump, and never a CLI-side re-derivation of the authorization call
|
|
402
|
+
// (the server's `decideContentRead` is the sole authority here).
|
|
403
|
+
const err = readCredentialError(minted);
|
|
404
|
+
if (err) {
|
|
405
|
+
throw new CliError(redact(err.message), { next: err.next });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const repos = Array.isArray(minted.repos) ? minted.repos : [];
|
|
409
|
+
const target = repos.find((r) => r.repo === tenant || r.owner === tenant) ?? repos[0];
|
|
410
|
+
if (!target || !target.gitRemote) {
|
|
411
|
+
throw new CliError(
|
|
412
|
+
`repo_read_credential minted no usable credential for tenant "${tenant}"`,
|
|
413
|
+
{ next: "confirm the tenant name, then re-run" },
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const gitRemote = target.gitRemote;
|
|
417
|
+
const u = new URL(gitRemote);
|
|
418
|
+
const publicUrl = `${u.protocol}//${u.host}${u.pathname}`;
|
|
419
|
+
|
|
420
|
+
if (!cloneDir) {
|
|
421
|
+
return { gitRemote, publicUrl, cloned: false, dir: null, head: null };
|
|
422
|
+
}
|
|
423
|
+
const { dir, head } = await cloneRepoAtPrRef(gitRemote, cloneDir, pr, redact);
|
|
424
|
+
return { gitRemote, publicUrl, cloned: true, dir, head };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* git clone the authenticated (READ-ONLY) remote into `dir`, then materialize
|
|
429
|
+
* PR #<pr>'s head by NUMBER — Gitea exposes every pull request at the standard
|
|
430
|
+
* `refs/pull/<N>/head` ref, so this needs no separate PR-metadata lookup (see
|
|
431
|
+
* the file header on why `repo_pr_read` isn't used here). Runs git as a
|
|
432
|
+
* NON-BLOCKING child process (promisified execFile), same as `cloneRepo`.
|
|
433
|
+
* `deps.git` is injectable for tests (an async `(cargs) => stdout` runner);
|
|
434
|
+
* defaults to the real `git` binary. Throws CliError on failure.
|
|
435
|
+
* @param {string} gitRemote
|
|
436
|
+
* @param {string} dir
|
|
437
|
+
* @param {number} pr
|
|
438
|
+
* @param {(s: string) => string} redact
|
|
439
|
+
* @param {{ git?: (cargs: string[]) => Promise<string> }} [deps]
|
|
440
|
+
*/
|
|
441
|
+
export async function cloneRepoAtPrRef(gitRemote, dir, pr, redact, deps = {}) {
|
|
442
|
+
const git = deps.git || (async (cargs) => (await execFileP("git", cargs)).stdout.toString());
|
|
443
|
+
console.log(`+ git clone → ${dir}`);
|
|
444
|
+
try {
|
|
445
|
+
await git(["clone", gitRemote, dir]);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
await emitObstacle("clone-failed");
|
|
448
|
+
throw new CliError(`clone failed: ${redact(String(e.stderr || e.message || e))}`, {
|
|
449
|
+
next: `check the target dir is empty and you can reach the remote, then re-run`,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
const prRef = `refs/pull/${pr}/head`;
|
|
453
|
+
console.log(`+ git fetch ${prRef}`);
|
|
454
|
+
try {
|
|
455
|
+
await git(["-C", dir, "fetch", "origin", prRef]);
|
|
456
|
+
await git(["-C", dir, "checkout", "-B", `pr-${pr}`, "FETCH_HEAD"]);
|
|
457
|
+
} catch (e) {
|
|
458
|
+
throw new CliError(
|
|
459
|
+
`couldn't fetch PR #${pr}: ${redact(String(e.stderr || e.message || e))}`,
|
|
460
|
+
{ next: `confirm PR #${pr} exists on this tenant — \`tot pr list --tenant <t>\`, then re-run` },
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
|
|
464
|
+
writeNvmrc(dir);
|
|
465
|
+
return { dir, head };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Classify a `repo_read_credential` tool result: null when it's a genuine,
|
|
470
|
+
* usable mint (carries at least one repo with a gitRemote), otherwise a human
|
|
471
|
+
* { message, next } pair — mirrors `checkoutError`'s contract exactly (never a
|
|
472
|
+
* raw JSON dump; the MCP's own denial reason + a concrete next step). Pure +
|
|
473
|
+
* exported so it's unit-tested without any I/O.
|
|
474
|
+
* @param {unknown} minted
|
|
475
|
+
* @returns {{ message: string, next: string }|null}
|
|
476
|
+
*/
|
|
477
|
+
export function readCredentialError(minted) {
|
|
478
|
+
const c = /** @type {any} */ (minted && typeof minted === "object" && !Array.isArray(minted) ? minted : null);
|
|
479
|
+
if (c && Array.isArray(c.repos) && c.repos.some((r) => r && r.gitRemote)) return null;
|
|
480
|
+
const msg =
|
|
481
|
+
(c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
|
|
482
|
+
(c && typeof c.raw === "string" && c.raw.trim() ? c.raw.trim() : null) ||
|
|
483
|
+
null;
|
|
484
|
+
if (typeof msg === "string" && /not authorized/i.test(msg)) {
|
|
485
|
+
return {
|
|
486
|
+
message: msg,
|
|
487
|
+
next:
|
|
488
|
+
"a cross-tenant read needs org-wide read authority, ownership of the target, or a " +
|
|
489
|
+
"per-tenant operator grant — ask your Token of Trust contact for one, or confirm the tenant name",
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
if (
|
|
493
|
+
typeof msg === "string" &&
|
|
494
|
+
/version-control app is bound|registered version-control app/i.test(msg)
|
|
495
|
+
) {
|
|
496
|
+
return {
|
|
497
|
+
message: "your account isn't onboarded to read this tenant's content yet",
|
|
498
|
+
next: "re-run `tot login` to refresh your access, then retry — if it persists, ask your " +
|
|
499
|
+
"Token of Trust contact to finish onboarding your account",
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
message: msg || "the read credential couldn't be minted",
|
|
504
|
+
next: "confirm the tenant name and that you're authorized to read it, then re-run",
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
244
508
|
/**
|
|
245
509
|
* Classify a `tenant_checkout` tool result: null when it's a genuine, usable
|
|
246
510
|
* checkout (carries a gitRemote), otherwise a human { message, next } pair so
|
|
@@ -258,7 +522,7 @@ async function cloneRepo(gitRemote, dir, redact) {
|
|
|
258
522
|
* @returns {{ message: string, next: string }|null}
|
|
259
523
|
*/
|
|
260
524
|
export function checkoutError(checkout) {
|
|
261
|
-
const c = checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null;
|
|
525
|
+
const c = /** @type {any} */ (checkout && typeof checkout === "object" && !Array.isArray(checkout) ? checkout : null);
|
|
262
526
|
if (c && c.gitRemote) return null; // a usable checkout — never an error
|
|
263
527
|
const msg =
|
|
264
528
|
(c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
|
|
@@ -271,6 +535,22 @@ export function checkoutError(checkout) {
|
|
|
271
535
|
next: "ask your Token of Trust contact to finish setting up your store, then re-run",
|
|
272
536
|
};
|
|
273
537
|
}
|
|
538
|
+
// Entitled, but not yet BOUND to the version-control app (appForCaller resolves
|
|
539
|
+
// nothing for this identity) — the 08-18 vc-app-binding gap. Distinct from
|
|
540
|
+
// not-provisioned: the STORE is fine; the developer just isn't onboarded to act
|
|
541
|
+
// THROUGH the app yet. The checkout preflight tries to self-heal this; if it still
|
|
542
|
+
// surfaces, the binding seam/backfill hasn't reached this identity — say so plainly
|
|
543
|
+
// instead of the generic "confirm you're entitled" (they ARE entitled).
|
|
544
|
+
if (
|
|
545
|
+
typeof msg === "string" &&
|
|
546
|
+
/version-control app is bound|registered version-control app/i.test(msg)
|
|
547
|
+
) {
|
|
548
|
+
return {
|
|
549
|
+
message:
|
|
550
|
+
"your account isn't onboarded to check out this store yet (no version-control app is bound to your session)",
|
|
551
|
+
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",
|
|
552
|
+
};
|
|
553
|
+
}
|
|
274
554
|
return {
|
|
275
555
|
message: msg || "the store checkout couldn't be completed",
|
|
276
556
|
next: "confirm you're entitled to this store — `tot clone` (lists your stores)",
|
|
@@ -285,7 +565,8 @@ export function checkoutError(checkout) {
|
|
|
285
565
|
* @returns {Array<{ id: string, name: string, raw: any }>}
|
|
286
566
|
*/
|
|
287
567
|
export function normalizeStores(list) {
|
|
288
|
-
const
|
|
568
|
+
const l = /** @type {any} */ (list);
|
|
569
|
+
const rows = Array.isArray(l) ? l : l?.clients || l?.tenants || [];
|
|
289
570
|
if (!Array.isArray(rows)) return [];
|
|
290
571
|
return rows
|
|
291
572
|
.map((r) => ({
|
|
@@ -311,23 +592,24 @@ export function normalizeStores(list) {
|
|
|
311
592
|
*/
|
|
312
593
|
export function storeListError(list) {
|
|
313
594
|
if (list == null || typeof list !== "object" || Array.isArray(list)) return null;
|
|
595
|
+
const l = /** @type {any} */ (list);
|
|
314
596
|
// A resolvable store array present → it succeeded, never an error.
|
|
315
|
-
if (Array.isArray(
|
|
597
|
+
if (Array.isArray(l.clients) || Array.isArray(l.tenants)) return null;
|
|
316
598
|
const msg =
|
|
317
|
-
|
|
318
|
-
(typeof
|
|
599
|
+
l.message ||
|
|
600
|
+
(typeof l.error === "string" ? l.error : l.error?.message) ||
|
|
319
601
|
null;
|
|
320
|
-
if (
|
|
321
|
-
if (typeof
|
|
322
|
-
return msg || `the store list request returned status "${
|
|
602
|
+
if (l.isError) return msg || "the store list request returned an error";
|
|
603
|
+
if (typeof l.status === "string" && !/^(ok|success)$/i.test(l.status)) {
|
|
604
|
+
return msg || `the store list request returned status "${l.status}"`;
|
|
323
605
|
}
|
|
324
|
-
if (
|
|
325
|
-
if (typeof
|
|
606
|
+
if (l.error) return msg || "the store list request returned an error";
|
|
607
|
+
if (typeof l.raw === "string" && l.raw.trim()) return l.raw.trim();
|
|
326
608
|
return null;
|
|
327
609
|
}
|
|
328
610
|
|
|
329
611
|
/**
|
|
330
|
-
* Extract
|
|
612
|
+
* Extract the broker-identity remediation signal from a `client_list`
|
|
331
613
|
* result. When the identity resolved ZERO stores, the server MAY carry:
|
|
332
614
|
* - `brokerStatus`: 'unlinked' | 'unconfigured' | 'broker_error' — present only
|
|
333
615
|
* when the empty scope is a BROKER-IDENTITY problem (not an entitlement one);
|
|
@@ -341,7 +623,7 @@ export function storeListError(list) {
|
|
|
341
623
|
* @returns {{ brokerStatus: string|null, nextAction: string|null }}
|
|
342
624
|
*/
|
|
343
625
|
export function brokerRemediation(list) {
|
|
344
|
-
const c = list && typeof list === "object" && !Array.isArray(list) ? list : null;
|
|
626
|
+
const c = /** @type {any} */ (list && typeof list === "object" && !Array.isArray(list) ? list : null);
|
|
345
627
|
const brokerStatus = c && typeof c.brokerStatus === "string" ? c.brokerStatus : null;
|
|
346
628
|
const nextAction =
|
|
347
629
|
c && typeof c.nextAction === "string" && c.nextAction.trim() ? c.nextAction.trim() : null;
|
|
@@ -350,8 +632,8 @@ export function brokerRemediation(list) {
|
|
|
350
632
|
|
|
351
633
|
/**
|
|
352
634
|
* Status-aware human guidance for an authenticated identity that resolved ZERO
|
|
353
|
-
* stores (
|
|
354
|
-
* a store invite" copy that dead-ended an UNLINKED identity). Driven by
|
|
635
|
+
* stores (fixes the misleading "may still be propagating / ask for
|
|
636
|
+
* a store invite" copy that dead-ended an UNLINKED identity). Driven by
|
|
355
637
|
* `brokerStatus`/`nextAction`:
|
|
356
638
|
* - `unlinked` → the identity isn't linked to the ToT broker yet, so no scope can
|
|
357
639
|
* resolve. This is NOT an entitlement problem — point at `tot link` (the
|
|
@@ -376,7 +658,7 @@ export function noStoresGuidance(list, { linkHint = "tot link" } = {}) {
|
|
|
376
658
|
|
|
377
659
|
if (brokerStatus === "unlinked") {
|
|
378
660
|
// The actionable terminal step beats the server's identity_link_begin/poll
|
|
379
|
-
// MCP-tool wording for a CLI user, so lead with `tot link` (
|
|
661
|
+
// MCP-tool wording for a CLI user, so lead with `tot link` (the optional-arm
|
|
380
662
|
// directive). Fall back to the server string only if there's no link action.
|
|
381
663
|
return {
|
|
382
664
|
brokerStatus,
|