@tokenoftrust/cli 2.1.2 → 2.2.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/bin/tot.mjs +1 -0
- package/package.json +1 -1
- package/src/commands/clone.mjs +240 -33
- package/src/commands/git-credential.mjs +88 -7
- package/src/commands/report.mjs +1 -1
- package/src/git-credential.mjs +58 -1
- package/src/mcp.mjs +3 -0
package/bin/tot.mjs
CHANGED
|
@@ -55,6 +55,7 @@ const COMMANDS = [
|
|
|
55
55
|
{ invocation: "link", blurb: "link your identity so your stores resolve" },
|
|
56
56
|
{ invocation: "grants", blurb: "capability/tier/expiry per store you can act on" },
|
|
57
57
|
{ invocation: "clone [<tenant>]", blurb: "clone a store you can build on" },
|
|
58
|
+
{ invocation: "clone agency-kit [dir]", blurb: "clone the Agency Kit, read-only (needs agency access from Token of Trust)" },
|
|
58
59
|
{ invocation: "validate", blurb: "lint your store before you submit" },
|
|
59
60
|
{ invocation: "capabilities", blurb: "what the platform supports, refuses, and has not built yet" },
|
|
60
61
|
{ invocation: "report go-live --pages <dir>", blurb: "your store's go-live readiness report, from its page-check results" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/commands/clone.mjs
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* tot clone <tenant> clone into ./<tenant> (authenticated remote configured)
|
|
11
11
|
* tot clone <tenant> <dir> clone into <dir>
|
|
12
12
|
* tot clone <tenant> --remote-only just mint + print the remote, don't materialize
|
|
13
|
+
* tot clone agency-kit [dir] READ-ONLY clone of the Agency Kit (ADR 0037)
|
|
14
|
+
*
|
|
15
|
+
* AGENCY KIT. `agency-kit` is an alias, not a tenant: it mints through
|
|
16
|
+
* `repo_read_credential` (see READ_ONLY_ALIASES / kitReadCredentialInput) and skips
|
|
17
|
+
* every store-only step. The checkout records its kind in local git config so
|
|
18
|
+
* `tot git-credential` re-mints it the same way.
|
|
13
19
|
*
|
|
14
20
|
* This is the SAME MCP code path a developer gets when they switch to a tenant:
|
|
15
21
|
* sign-in → client_switch(tenant) → tenant_checkout, where the MCP derives your
|
|
@@ -54,7 +60,9 @@ import { offerSignIn } from "./login.mjs";
|
|
|
54
60
|
import { CliError, fail, formatError } from "../errors.mjs";
|
|
55
61
|
import { writeNvmrc } from "../sample.mjs";
|
|
56
62
|
import { emitObstacle } from "../obstacle.mjs";
|
|
57
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
splitAuthedRemote, basicAuthExtraHeader, installForgeCredentialHelper, recordReadOnlyKitCheckout,
|
|
65
|
+
} from "../git-credential.mjs";
|
|
58
66
|
import { recordDiagnostic } from "../diagnostics.mjs";
|
|
59
67
|
|
|
60
68
|
const execFileP = promisify(execFile);
|
|
@@ -115,6 +123,8 @@ const USAGE = `tot clone — clone a tenant store you can build on (mirrors \`gi
|
|
|
115
123
|
(works even on a tenant you don't own/aren't a
|
|
116
124
|
member of, when authorized; dir defaults to
|
|
117
125
|
<tenant>-pr<N>)
|
|
126
|
+
tot clone agency-kit [dir] clone the Agency Kit, READ-ONLY, into ./agency-kit
|
|
127
|
+
(needs agency access — ask Token of Trust)
|
|
118
128
|
|
|
119
129
|
Options:
|
|
120
130
|
--tag <tag> deprecated compatibility input; tenant repos use the bare domain.
|
|
@@ -140,14 +150,22 @@ function makeRedactor(env) {
|
|
|
140
150
|
* @param {string[]} argv - args AFTER `checkout`
|
|
141
151
|
* @param {import("../context.mjs").detectContext extends (...a:any)=>infer R ? R : any} ctx
|
|
142
152
|
*/
|
|
143
|
-
export async function run(argv, ctx) {
|
|
144
|
-
const
|
|
153
|
+
export async function run(argv, ctx, deps = {}) {
|
|
154
|
+
const {
|
|
155
|
+
env = process.env, createClient = createMcpClient, establish = establishSession,
|
|
156
|
+
signIn = offerSignIn, cloneDeps = {},
|
|
157
|
+
} = deps;
|
|
145
158
|
const args = parseArgs(argv);
|
|
146
159
|
if (args.help) {
|
|
147
160
|
console.log(USAGE);
|
|
148
161
|
return 0;
|
|
149
162
|
}
|
|
150
163
|
|
|
164
|
+
const alias = resolveReadOnlyAlias(args.tenant);
|
|
165
|
+
if (alias) {
|
|
166
|
+
return runReadOnlyAlias(alias, args, { env, createClient, establish, signIn, cloneDeps });
|
|
167
|
+
}
|
|
168
|
+
|
|
151
169
|
// Infer the tenant from a standalone checkout when run without one.
|
|
152
170
|
if (!args.tenant && ctx.mode === "checkout" && ctx.tenant) {
|
|
153
171
|
args.tenant = ctx.tenant;
|
|
@@ -169,24 +187,10 @@ export async function run(argv, ctx) {
|
|
|
169
187
|
|
|
170
188
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
171
189
|
const redact = makeRedactor(env);
|
|
172
|
-
const client =
|
|
190
|
+
const client = createClient(baseUrl);
|
|
173
191
|
|
|
174
192
|
try {
|
|
175
|
-
|
|
176
|
-
// BEFORE initialize) — see establishSession. When there's no session yet and
|
|
177
|
-
// we're on a TTY, offer to sign in inline and retry once, so a not-signed-in
|
|
178
|
-
// developer isn't dead-ended at "run tot login, then re-run".
|
|
179
|
-
try {
|
|
180
|
-
await establishSession(client, { env });
|
|
181
|
-
} catch (e) {
|
|
182
|
-
if (e instanceof AuthUnavailableError && e.reason === "missing") {
|
|
183
|
-
const signedIn = await offerSignIn(client.mcpUrl, env, {});
|
|
184
|
-
if (!signedIn) throw e; // declined/non-TTY → the crisp error below
|
|
185
|
-
await establishSession(client, { env }); // retry once, in-flow
|
|
186
|
-
} else {
|
|
187
|
-
throw e;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
193
|
+
await signInOrOffer(client, { env, establish, signIn });
|
|
190
194
|
console.error(`~ signed in → ${client.mcpUrl}`);
|
|
191
195
|
|
|
192
196
|
// No tenant → list the stores this identity can build on and stop.
|
|
@@ -262,17 +266,80 @@ export async function run(argv, ctx) {
|
|
|
262
266
|
}
|
|
263
267
|
return 0;
|
|
264
268
|
} catch (e) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
269
|
+
return reportFailure(e, redact);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Attach auth in the right order relative to the handshake (developer bearer BEFORE
|
|
275
|
+
* initialize) — see establishSession. When there's no session yet and we're on a TTY,
|
|
276
|
+
* offer to sign in inline and retry once, so a not-signed-in developer isn't dead-ended
|
|
277
|
+
* at "run tot login, then re-run".
|
|
278
|
+
*/
|
|
279
|
+
async function signInOrOffer(client, { env, establish, signIn }) {
|
|
280
|
+
try {
|
|
281
|
+
await establish(client, { env });
|
|
282
|
+
} catch (e) {
|
|
283
|
+
if (e instanceof AuthUnavailableError && e.reason === "missing") {
|
|
284
|
+
const signedIn = await signIn(client.mcpUrl, env, {});
|
|
285
|
+
if (!signedIn) throw e; // declined/non-TTY → the crisp error below
|
|
286
|
+
await establish(client, { env }); // retry once, in-flow
|
|
287
|
+
} else {
|
|
288
|
+
throw e;
|
|
272
289
|
}
|
|
273
|
-
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Map a thrown error to the CLI's exit + message, the same way for every clone path. */
|
|
294
|
+
function reportFailure(e, redact) {
|
|
295
|
+
if (e instanceof AuthUnavailableError) {
|
|
296
|
+
console.error(formatError(e));
|
|
274
297
|
return 1;
|
|
275
298
|
}
|
|
299
|
+
if (e instanceof CliError) {
|
|
300
|
+
console.error(formatError(e));
|
|
301
|
+
return e.exitCode ?? 1;
|
|
302
|
+
}
|
|
303
|
+
console.error(fail(`checkout failed: ${redact(String(/** @type {any} */ (e)?.message || e))}`));
|
|
304
|
+
return 1;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* `tot clone <alias> [dir]` for a read-only alias (the agency kit). Only the steps a
|
|
309
|
+
* non-store repo needs: sign-in, the read mint, the token-free clone, the helper. No store
|
|
310
|
+
* listing, client_switch, identity_bind, tenant_checkout, tenant inference, `.nvmrc` or
|
|
311
|
+
* `tot dev` guidance.
|
|
312
|
+
*/
|
|
313
|
+
async function runReadOnlyAlias(alias, args, { env, createClient, establish, signIn, cloneDeps }) {
|
|
314
|
+
const redact = makeRedactor(env);
|
|
315
|
+
if (args.pr) {
|
|
316
|
+
console.error(fail(`\`--pr\` does not apply to ${alias.repo}`, `tot clone ${alias.repo} [dir]`));
|
|
317
|
+
return 2;
|
|
318
|
+
}
|
|
319
|
+
const cloneDir = args.remoteOnly ? null : args.dir || alias.defaultDir;
|
|
320
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
321
|
+
const client = createClient(baseUrl);
|
|
322
|
+
try {
|
|
323
|
+
await signInOrOffer(client, { env, establish, signIn });
|
|
324
|
+
console.error(`~ signed in → ${client.mcpUrl}`);
|
|
325
|
+
console.error(`~ repo_read_credential (${alias.target}) — MCP mints a read-only credential`);
|
|
326
|
+
const res = await cloneReadOnlyAlias(client, { alias, cloneDir, redact }, cloneDeps);
|
|
327
|
+
console.log(`\n+ ready (read-only). repo: ${res.publicUrl}`);
|
|
328
|
+
if (res.cloned) {
|
|
329
|
+
console.log(`+ cloned. HEAD: ${res.head}`);
|
|
330
|
+
console.log(`\nYour read-only ${alias.repo} checkout is at ${res.dir}.`);
|
|
331
|
+
console.log(` (it can pull, never push — \`git pull\` mints a fresh read credential each time)`);
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
console.log(`\nPublic clone URL (no credential): ${res.publicUrl}`);
|
|
335
|
+
console.log(`Drop --remote-only to clone ${alias.repo} with the read-only remote configured.`);
|
|
336
|
+
if (args.printRemote) {
|
|
337
|
+
console.log(`\nREAD-ONLY remote (contains a live token — handle carefully):\n${res.gitRemote}`);
|
|
338
|
+
}
|
|
339
|
+
return 0;
|
|
340
|
+
} catch (e) {
|
|
341
|
+
return reportFailure(e, redact);
|
|
342
|
+
}
|
|
276
343
|
}
|
|
277
344
|
|
|
278
345
|
/**
|
|
@@ -428,8 +495,25 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
|
|
|
428
495
|
* `Promise.all([...])` alongside the renderer prefetch, and a synchronous
|
|
429
496
|
* clone would serialize what's meant to overlap. Throws CliError on failure.
|
|
430
497
|
*/
|
|
431
|
-
|
|
432
|
-
|
|
498
|
+
/**
|
|
499
|
+
* @param {string} gitRemote
|
|
500
|
+
* @param {string} dir
|
|
501
|
+
* @param {(s: string) => string} redact
|
|
502
|
+
* @param {{
|
|
503
|
+
* store?: boolean,
|
|
504
|
+
* git?: (cargs: string[]) => Promise<string>,
|
|
505
|
+
* gitSync?: (cargs: string[]) => string,
|
|
506
|
+
* installHelper?: (git: (cargs: string[]) => string, opts: { host?: string }) => boolean,
|
|
507
|
+
* record?: ((gitSync: (cargs: string[]) => string) => void) | null,
|
|
508
|
+
* }} [opts]
|
|
509
|
+
*/
|
|
510
|
+
async function cloneRepo(gitRemote, dir, redact, {
|
|
511
|
+
store = true,
|
|
512
|
+
git = async (/** @type {string[]} */ cargs) => (await execFileP("git", cargs)).stdout.toString(),
|
|
513
|
+
gitSync = (/** @type {string[]} */ cargs) => execFileSync("git", ["-C", dir, ...cargs], { encoding: "utf8" }),
|
|
514
|
+
installHelper = installForgeCredentialHelper,
|
|
515
|
+
record = null,
|
|
516
|
+
} = {}) {
|
|
433
517
|
console.log(`+ git clone → ${dir}`);
|
|
434
518
|
const cred = splitAuthedRemote(gitRemote);
|
|
435
519
|
const cloneArgs = cred
|
|
@@ -458,9 +542,7 @@ async function cloneRepo(gitRemote, dir, redact) {
|
|
|
458
542
|
// like a missing repo rather than a credential problem. The host-scoped form writes
|
|
459
543
|
// ["", <helper>]: the empty reset clears the inherited helper for this host only.
|
|
460
544
|
try {
|
|
461
|
-
|
|
462
|
-
execFileSync("git", ["-C", dir, ...cargs], { encoding: "utf8" });
|
|
463
|
-
installForgeCredentialHelper(gitSync, { host: new URL(gitRemote).host });
|
|
545
|
+
installHelper(gitSync, { host: new URL(gitRemote).host });
|
|
464
546
|
} catch (error) {
|
|
465
547
|
// Best-effort — never fail a good clone over credential plumbing. The next
|
|
466
548
|
// `tot preview`/`tot sync` installs it, and the fetch that needs it says why.
|
|
@@ -468,11 +550,136 @@ async function cloneRepo(gitRemote, dir, redact) {
|
|
|
468
550
|
command: "clone", operation: "credential_helper_install", required: false, degraded: true,
|
|
469
551
|
});
|
|
470
552
|
}
|
|
553
|
+
if (record) {
|
|
554
|
+
// Not best-effort: without the record, the credential helper cannot tell what this
|
|
555
|
+
// checkout is, so every later fetch would fail with nothing to say why.
|
|
556
|
+
try {
|
|
557
|
+
record(gitSync);
|
|
558
|
+
} catch (e) {
|
|
559
|
+
throw new CliError(`couldn't record the checkout kind in ${dir}: ${redact(String(e?.message || e))}`, {
|
|
560
|
+
next: `remove ${dir} and re-run`,
|
|
561
|
+
cause: e,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
}
|
|
471
565
|
const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
|
|
472
|
-
writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
|
|
566
|
+
if (store) writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
|
|
473
567
|
return { dir, head };
|
|
474
568
|
}
|
|
475
569
|
|
|
570
|
+
// ─── read-only aliases: `tot clone agency-kit` ─────────────────────────────────
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Repos `tot clone` resolves by NAME rather than as a tenant store. Each is a read-only
|
|
574
|
+
* checkout minted through `repo_read_credential`; the server maps the target to its repo
|
|
575
|
+
* (ADR 0037), so the CLI never names an owner or an org. `repo` is what the minted
|
|
576
|
+
* credential must be for — anything else is refused rather than cloned.
|
|
577
|
+
*/
|
|
578
|
+
export const READ_ONLY_ALIASES = Object.freeze({
|
|
579
|
+
"agency-kit": Object.freeze({ target: "agency-kit", repo: "agency-kit", defaultDir: "agency-kit" }),
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
/** @param {string|null|undefined} name */
|
|
583
|
+
export function resolveReadOnlyAlias(name) {
|
|
584
|
+
return name && Object.hasOwn(READ_ONLY_ALIASES, name) ? READ_ONLY_ALIASES[name] : null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The `repo_read_credential` input for a read-only alias. The one place the wire shape
|
|
588
|
+
* lives. `alias` is the ONLY key: the server refuses it alongside `tenant`/`repo`, and a
|
|
589
|
+
* key the server does not declare is dropped by the MCP SDK, which then mints the caller's
|
|
590
|
+
* own store. */
|
|
591
|
+
export function kitReadCredentialInput(target) {
|
|
592
|
+
return { alias: target };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export const KIT_DENIED_MESSAGE = "your agency does not have kit access; ask Token of Trust";
|
|
596
|
+
export const KIT_INVALID_MESSAGE = "the kit could not be requested";
|
|
597
|
+
|
|
598
|
+
/** Did the server REFUSE the request (as opposed to a transport fault or an outage)?
|
|
599
|
+
* Returns `"forbidden"`, `"invalid_input"`, or null. Reads the tool's typed status, from a
|
|
600
|
+
* thrown tool error or a returned result; message text is never the signal. Pure. */
|
|
601
|
+
export function readRefusal(source) {
|
|
602
|
+
const e = /** @type {any} */ (source);
|
|
603
|
+
if (!e) return null;
|
|
604
|
+
const category = e?.diagnostic?.category ?? e?.category;
|
|
605
|
+
if (category && category !== "tool_error") return null;
|
|
606
|
+
const status = e?.diagnostic?.serverStatus ?? e?.serverStatus ?? e?.status;
|
|
607
|
+
return status === "forbidden" || status === "invalid_input" ? status : null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The server's own words from a failed mint, never an invented reason. */
|
|
611
|
+
function serverReason(error) {
|
|
612
|
+
const e = /** @type {any} */ (error);
|
|
613
|
+
const m = e?.diagnostic?.message ?? e?.message ?? (typeof e?.error === "string" ? e.error : e?.error?.message);
|
|
614
|
+
return typeof m === "string" && m.trim() ? m.trim() : null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Mint a read-only credential for a read-only alias. Calls ONLY `repo_read_credential` —
|
|
619
|
+
* no store tool — and refuses anything that is not a credential for the alias's own repo
|
|
620
|
+
* (a server that dropped the `alias` key would otherwise hand back the caller's own store).
|
|
621
|
+
* The server reports no expiry — it reaps the reader token after ~24h — so the helper's TTL
|
|
622
|
+
* and erase→re-mint are what bound a cached copy.
|
|
623
|
+
* @param {{ callTool: (name: string, args: unknown) => Promise<any> }} client
|
|
624
|
+
* @param {{ target: string, repo: string }} alias
|
|
625
|
+
* @param {(s: string) => string} [redact]
|
|
626
|
+
* @returns {Promise<{ gitRemote: string, publicUrl: string }>}
|
|
627
|
+
*/
|
|
628
|
+
export async function mintReadOnlyAlias(client, alias, redact = (s) => s) {
|
|
629
|
+
/** @param {unknown} source the thrown error or the returned non-mint result */
|
|
630
|
+
const fail = (source, cause) => {
|
|
631
|
+
const reason = serverReason(source);
|
|
632
|
+
const said = reason ? `Token of Trust said: ${redact(reason)}` : undefined;
|
|
633
|
+
const refusal = readRefusal(source);
|
|
634
|
+
if (refusal === "forbidden") return new CliError(KIT_DENIED_MESSAGE, { next: said, cause });
|
|
635
|
+
if (refusal === "invalid_input") return new CliError(KIT_INVALID_MESSAGE, { next: said, cause });
|
|
636
|
+
return new CliError(`the ${alias.repo} credential couldn't be minted${reason ? `: ${redact(reason)}` : ""}`, {
|
|
637
|
+
next: "check your connection and `tot whoami`, then re-run",
|
|
638
|
+
cause,
|
|
639
|
+
});
|
|
640
|
+
};
|
|
641
|
+
let minted;
|
|
642
|
+
try {
|
|
643
|
+
minted = await client.callTool("repo_read_credential", kitReadCredentialInput(alias.target));
|
|
644
|
+
} catch (e) {
|
|
645
|
+
throw fail(e, e);
|
|
646
|
+
}
|
|
647
|
+
const m = /** @type {any} */ (minted && typeof minted === "object" ? minted : {});
|
|
648
|
+
const repos = Array.isArray(m.repos) ? m.repos : [];
|
|
649
|
+
if (!repos.some((r) => r && r.gitRemote)) throw fail(m, undefined);
|
|
650
|
+
const target = repos.find((r) => r && r.repo === alias.repo && r.gitRemote);
|
|
651
|
+
if (!target) {
|
|
652
|
+
throw new CliError(`Token of Trust returned no credential for ${alias.repo}`, {
|
|
653
|
+
next: "this server may not support kit checkout yet — ask Token of Trust",
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
const gitRemote = String(target.gitRemote);
|
|
657
|
+
const u = new URL(gitRemote);
|
|
658
|
+
return {
|
|
659
|
+
gitRemote,
|
|
660
|
+
publicUrl: `${u.protocol}//${u.host}${u.pathname}`,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* `tot clone agency-kit [dir]`: mint the read-only credential, clone token-free through a
|
|
666
|
+
* one-shot `http.extraheader`, install the host-scoped credential helper, and record the
|
|
667
|
+
* checkout as a read-only kit clone so the helper re-mints it the same way.
|
|
668
|
+
* @param {{ callTool: (name: string, args: unknown) => Promise<any> }} client
|
|
669
|
+
* @param {{ alias: { target: string, repo: string }, cloneDir?: string|null, redact?: (s:string)=>string }} opts
|
|
670
|
+
* @param {Parameters<typeof cloneRepo>[3]} [deps]
|
|
671
|
+
*/
|
|
672
|
+
export async function cloneReadOnlyAlias(client, { alias, cloneDir = null, redact = (s) => s }, deps = {}) {
|
|
673
|
+
const { gitRemote, publicUrl } = await mintReadOnlyAlias(client, alias, redact);
|
|
674
|
+
if (!cloneDir) return { gitRemote, publicUrl, cloned: false, dir: null, head: null };
|
|
675
|
+
const { dir, head } = await cloneRepo(gitRemote, cloneDir, redact, {
|
|
676
|
+
...deps,
|
|
677
|
+
store: false,
|
|
678
|
+
record: (gitSync) => recordReadOnlyKitCheckout(gitSync, alias.target),
|
|
679
|
+
});
|
|
680
|
+
return { gitRemote, publicUrl, cloned: true, dir, head };
|
|
681
|
+
}
|
|
682
|
+
|
|
476
683
|
/**
|
|
477
684
|
* The cross-tenant PR-clone core, composable in-process exactly like
|
|
478
685
|
* `checkoutTenant`: mint a READ-ONLY credential via `repo_read_credential`, then
|
|
@@ -12,11 +12,15 @@
|
|
|
12
12
|
* a checkout's remote URL at clone time.
|
|
13
13
|
*
|
|
14
14
|
* git invokes this with ONE positional arg (`get`/`store`/`erase`) and the
|
|
15
|
-
* request on stdin (key=value lines, blank-line/EOF terminated).
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* request on stdin (key=value lines, blank-line/EOF terminated). `get` mints
|
|
16
|
+
* (or reuses the short-lived cache in ../git-credential.mjs); `erase`, which git
|
|
17
|
+
* sends when the forge rejected the credential, drops that cache so the next
|
|
18
|
+
* `get` re-mints; `store` is a no-op.
|
|
19
|
+
*
|
|
20
|
+
* Two checkout kinds: a tenant store (its `.tot/config.json`) mints a push
|
|
21
|
+
* credential through `tenant_checkout`; a read-only kit clone (recorded in local
|
|
22
|
+
* git config by `tot clone agency-kit`) mints through `repo_read_credential`,
|
|
23
|
+
* cached under a key a store's can never take.
|
|
20
24
|
*
|
|
21
25
|
* FAILS SILENT, NEVER LOUD: `get` prints NOTHING and exits non-zero on any
|
|
22
26
|
* problem (not signed in, MCP unreachable, cwd isn't a recognizable tenant
|
|
@@ -34,12 +38,13 @@ import { readFileSync } from "node:fs";
|
|
|
34
38
|
import { execFileSync } from "node:child_process";
|
|
35
39
|
import { createMcpClient } from "../mcp.mjs";
|
|
36
40
|
import { establishSession } from "../auth.mjs";
|
|
37
|
-
import { checkoutTenant } from "./clone.mjs";
|
|
41
|
+
import { checkoutTenant, mintReadOnlyAlias, resolveReadOnlyAlias } from "./clone.mjs";
|
|
38
42
|
import { detectContext } from "../context.mjs";
|
|
39
43
|
import { repoNameFromRemote, tagFromRepoName } from "./submit.mjs";
|
|
40
44
|
import {
|
|
41
45
|
parseCredentialInput, formatCredentialOutput, splitAuthedRemote,
|
|
42
46
|
credentialCachePath, readCachedCredential, writeCachedCredential,
|
|
47
|
+
kitCredentialCachePath, dropCachedCredential, readCheckoutKind, READ_ONLY_KIT_KIND,
|
|
43
48
|
} from "../git-credential.mjs";
|
|
44
49
|
|
|
45
50
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
@@ -97,13 +102,38 @@ export function readStdin() {
|
|
|
97
102
|
* createClient?: typeof createMcpClient,
|
|
98
103
|
* establish?: typeof establishSession,
|
|
99
104
|
* checkout?: typeof checkoutTenant,
|
|
105
|
+
* mintReadOnly?: typeof mintReadOnlyAlias,
|
|
100
106
|
* }} [opts]
|
|
101
107
|
* @returns {Promise<{username:string,password:string}|null>}
|
|
102
108
|
*/
|
|
103
109
|
export async function mintOrCacheCredential({
|
|
104
110
|
env = process.env, cwd = process.cwd(), expectedHost = null,
|
|
105
111
|
createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
|
|
112
|
+
mintReadOnly = mintReadOnlyAlias,
|
|
106
113
|
} = {}) {
|
|
114
|
+
const kit = kitCheckout(cwd);
|
|
115
|
+
if (kit) {
|
|
116
|
+
if (expectedHost && !hostMatches(kit.originUrl, expectedHost)) return null;
|
|
117
|
+
const alias = resolveReadOnlyAlias(kit.target);
|
|
118
|
+
if (!alias) return null; // a recorded target this CLI does not know — never guess a mint
|
|
119
|
+
const cachePath = kitCredentialCachePath(alias.target, env);
|
|
120
|
+
const cached = readCachedCredential(cachePath);
|
|
121
|
+
if (cached) return { username: cached.username, password: cached.password };
|
|
122
|
+
const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
123
|
+
const client = createClient(baseUrl);
|
|
124
|
+
try {
|
|
125
|
+
await establish(client, { env });
|
|
126
|
+
const res = await mintReadOnly(client, alias);
|
|
127
|
+
const cred = splitAuthedRemote(res.gitRemote || "");
|
|
128
|
+
if (!cred) return null;
|
|
129
|
+
const fresh = { username: cred.username, password: cred.token };
|
|
130
|
+
writeCachedCredential(cachePath, fresh);
|
|
131
|
+
return fresh;
|
|
132
|
+
} catch {
|
|
133
|
+
return null; // not signed in / MCP unreachable / no kit access — say nothing
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
107
137
|
const ctx = detectContext(cwd);
|
|
108
138
|
if (ctx.mode !== "checkout" || !ctx.tenant) return null;
|
|
109
139
|
|
|
@@ -143,6 +173,52 @@ export async function mintOrCacheCredential({
|
|
|
143
173
|
}
|
|
144
174
|
}
|
|
145
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The read-only kit checkout `cwd` is in, from the kind `tot clone` recorded, or null when
|
|
178
|
+
* it is not one (a store, a loose dir, not a repo). Never throws.
|
|
179
|
+
* @param {string} cwd
|
|
180
|
+
* @returns {{ target: string, originUrl: string|null }|null}
|
|
181
|
+
*/
|
|
182
|
+
export function kitCheckout(cwd) {
|
|
183
|
+
const git = (cargs) =>
|
|
184
|
+
execFileSync("git", ["-C", cwd, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
185
|
+
const kind = readCheckoutKind(git);
|
|
186
|
+
if (kind.kind !== READ_ONLY_KIT_KIND) return null;
|
|
187
|
+
let originUrl = null;
|
|
188
|
+
try { originUrl = git(["remote", "get-url", "origin"]).trim(); } catch { /* host check fails closed */ }
|
|
189
|
+
return { target: kind.target, originUrl };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* git reported the credential rejected (`erase`): drop this checkout's cached credential so
|
|
194
|
+
* the next `get` mints a fresh one instead of replaying the rejected token. Scoped to the
|
|
195
|
+
* checkout's own origin host, like `get`. Never throws.
|
|
196
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string, expectedHost?: string|null }} [opts]
|
|
197
|
+
*/
|
|
198
|
+
export function forgetCredential({ env = process.env, cwd = process.cwd(), expectedHost = null } = {}) {
|
|
199
|
+
try {
|
|
200
|
+
const kit = kitCheckout(cwd);
|
|
201
|
+
if (kit) {
|
|
202
|
+
if (expectedHost && !hostMatches(kit.originUrl, expectedHost)) return;
|
|
203
|
+
dropCachedCredential(kitCredentialCachePath(kit.target, env));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const ctx = detectContext(cwd);
|
|
207
|
+
if (ctx.mode !== "checkout" || !ctx.tenant || !ctx.workspacePath) return;
|
|
208
|
+
let originUrl = null;
|
|
209
|
+
try {
|
|
210
|
+
originUrl = execFileSync("git", ["-C", ctx.workspacePath, "remote", "get-url", "origin"], {
|
|
211
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
212
|
+
}).toString().trim();
|
|
213
|
+
} catch { /* no origin */ }
|
|
214
|
+
if (expectedHost && !hostMatches(originUrl, expectedHost)) return;
|
|
215
|
+
const tag = tagFromRepoName(originUrl ? repoNameFromRemote(originUrl) : null, ctx.tenant);
|
|
216
|
+
dropCachedCredential(credentialCachePath(ctx.tenant, tag, env));
|
|
217
|
+
} catch {
|
|
218
|
+
/* erase is advisory — never fail git over it */
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
146
222
|
const USAGE = `tot git-credential — git credential-helper protocol over your \`tot login\` session
|
|
147
223
|
|
|
148
224
|
Configured automatically by \`tot clone\` (credential.helper = !tot git-credential)
|
|
@@ -169,7 +245,12 @@ export async function run(argv, _ctx, opts = {}) {
|
|
|
169
245
|
// so the subprocess exits cleanly instead of leaving git's write blocked on a full pipe.
|
|
170
246
|
const request = parseCredentialInput(read());
|
|
171
247
|
|
|
172
|
-
if (op
|
|
248
|
+
if (op === "erase") {
|
|
249
|
+
// git rejected the credential it was given: forget it, so the next `get` re-mints.
|
|
250
|
+
forgetCredential({ env, cwd: opts.cwd, expectedHost: request.host });
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
if (op !== "get") return 0; // store: stateless, nothing to persist.
|
|
173
254
|
|
|
174
255
|
const cred = await mintOrCacheCredential({ ...opts, expectedHost: request.host });
|
|
175
256
|
if (!cred) return 1; // silent — let git fall through to its next helper / its own prompt.
|
package/src/commands/report.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import { validateTenant, ERROR } from "../validate.mjs";
|
|
|
27
27
|
import { fail } from "../errors.mjs";
|
|
28
28
|
import { tenantDirSegments } from "../tenant-dirs.mjs";
|
|
29
29
|
|
|
30
|
-
export const TEASER = "Site-wide validation with triage across every page, and the full migration method, are in the Agency Kit.";
|
|
30
|
+
export const TEASER = "Site-wide validation with triage across every page, and the full migration method, are in the Agency Kit. To get it, ask Token of Trust for agency access, then run `tot clone agency-kit`.";
|
|
31
31
|
|
|
32
32
|
export const USAGE = `tot report go-live — the go-live readiness report for your store
|
|
33
33
|
|
package/src/git-credential.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { join, dirname } from "node:path";
|
|
23
|
-
import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
|
23
|
+
import { mkdirSync, writeFileSync, chmodSync, rmSync } from "node:fs";
|
|
24
24
|
import { readCredentials, writeCredentials } from "./token-store.mjs";
|
|
25
25
|
|
|
26
26
|
/** The git config value that routes credential requests through `tot` — the
|
|
@@ -122,12 +122,69 @@ export function credentialCachePath(tenant, tag, env = process.env) {
|
|
|
122
122
|
return join(home, ".tot", "git-credentials", `${credentialCacheKey(tenant, tag)}.json`);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/** Cache key for a READ-ONLY kit checkout's credential. The `kit-` prefix is the
|
|
126
|
+
* separation: a tenant store's key is always 32 bare hex characters, so no tenant or tag
|
|
127
|
+
* (however named) can produce a file name this function produces, or the reverse. Pure. */
|
|
128
|
+
export function kitCredentialCacheKey(target) {
|
|
129
|
+
return `kit-${createHash("sha256").update(`kit|${target}`).digest("hex").slice(0, 32)}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Absolute path to a read-only kit checkout's cached credential. */
|
|
133
|
+
export function kitCredentialCachePath(target, env = process.env) {
|
|
134
|
+
const home = env.TOT_HOME || homedir();
|
|
135
|
+
return join(home, ".tot", "git-credentials", `${kitCredentialCacheKey(target)}.json`);
|
|
136
|
+
}
|
|
137
|
+
|
|
125
138
|
/** Is a cached credential still trusted? A cache with no `mintedAt` is
|
|
126
139
|
* treated as stale (mint fresh rather than trust an unknown age). Pure. */
|
|
127
140
|
export function isFreshCredential(cred, { now = Date.now(), ttlMs = CREDENTIAL_TTL_MS } = {}) {
|
|
128
141
|
return Boolean(cred && cred.username && cred.password && cred.mintedAt && now - cred.mintedAt < ttlMs);
|
|
129
142
|
}
|
|
130
143
|
|
|
144
|
+
/** Drop a cached credential (git reported it rejected). Never throws. */
|
|
145
|
+
export function dropCachedCredential(filePath) {
|
|
146
|
+
try {
|
|
147
|
+
rmSync(filePath, { force: true });
|
|
148
|
+
} catch {
|
|
149
|
+
/* nothing cached, or unremovable — the next `get` re-mints either way */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The checkout kinds `tot` records in a checkout's local git config. A tenant store
|
|
154
|
+
* carries none (it is recognized by its `.tot/config.json`); a read-only kit clone is
|
|
155
|
+
* recorded explicitly, because nothing else in it says what to mint. */
|
|
156
|
+
export const CHECKOUT_KIND_KEY = "tot.checkoutKind";
|
|
157
|
+
export const READ_TARGET_KEY = "tot.readTarget";
|
|
158
|
+
export const READ_ONLY_KIT_KIND = "read-only-kit";
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Record in a freshly cloned checkout that it is a read-only kit clone of `target`, so
|
|
162
|
+
* `tot git-credential` re-mints it through `repo_read_credential` instead of treating it
|
|
163
|
+
* as a store. Throws on a git failure (the caller decides how loud to be).
|
|
164
|
+
* @param {(cargs: string[]) => unknown} git a `git -C <checkout>` runner
|
|
165
|
+
* @param {string} target
|
|
166
|
+
*/
|
|
167
|
+
export function recordReadOnlyKitCheckout(git, target) {
|
|
168
|
+
git(["config", "--local", CHECKOUT_KIND_KEY, READ_ONLY_KIT_KIND]);
|
|
169
|
+
git(["config", "--local", READ_TARGET_KEY, target]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Read which kind of checkout `git` runs in: `{ kind: "read-only-kit", target }` for a
|
|
174
|
+
* recorded kit clone, else `{ kind: "store" }` (the default; the store path then applies
|
|
175
|
+
* its own checks). An unrecorded or unreadable config is a store, never a kit.
|
|
176
|
+
* @param {(cargs: string[]) => string} git
|
|
177
|
+
* @returns {{ kind: "read-only-kit", target: string } | { kind: "store" }}
|
|
178
|
+
*/
|
|
179
|
+
export function readCheckoutKind(git) {
|
|
180
|
+
const get = (key) => {
|
|
181
|
+
try { return String(git(["config", "--local", "--get", key])).trim(); } catch { return ""; }
|
|
182
|
+
};
|
|
183
|
+
if (get(CHECKOUT_KIND_KEY) !== READ_ONLY_KIT_KIND) return { kind: "store" };
|
|
184
|
+
const target = get(READ_TARGET_KEY);
|
|
185
|
+
return target ? { kind: READ_ONLY_KIT_KIND, target } : { kind: "store" };
|
|
186
|
+
}
|
|
187
|
+
|
|
131
188
|
/** Read a cached credential (reusing token-store.mjs's generic reader), or
|
|
132
189
|
* null if absent/unreadable/malformed/stale. Never throws. */
|
|
133
190
|
export function readCachedCredential(filePath, opts = {}) {
|
package/src/mcp.mjs
CHANGED
|
@@ -315,6 +315,9 @@ export function createMcpClient(baseUrl, opts = {}) {
|
|
|
315
315
|
throw diagnosticError(`${name} failed: ${message}`, {
|
|
316
316
|
operation: name, category: "tool_error",
|
|
317
317
|
serverCode: payload?.code || payload?.error?.code,
|
|
318
|
+
// The tool's own outcome (`forbidden`, `unauthenticated`, …) — the typed signal a
|
|
319
|
+
// caller classifies a refusal by, instead of matching message text.
|
|
320
|
+
serverStatus: typeof payload?.status === "string" ? payload.status : undefined,
|
|
318
321
|
message,
|
|
319
322
|
traceId: payload?.traceId || responseContext.traceId,
|
|
320
323
|
requestId: payload?.requestId || responseContext.requestId,
|