@tokenoftrust/cli 1.4.0-rc.20 → 1.4.0-rc.22

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.
@@ -23,11 +23,11 @@ export async function run(argv, ctx) {
23
23
 
24
24
  if (sub === "scaffold") {
25
25
  const { run: runScaffold } = await import("./scaffold.mjs");
26
- return runScaffold(rest, ctx);
26
+ return /** @type {any} */ (runScaffold)(rest, ctx);
27
27
  }
28
28
  if (sub === "dev") {
29
29
  const { run: runDev } = await import("./dev.mjs");
30
- return runDev(rest, ctx);
30
+ return /** @type {any} */ (runDev)(rest, ctx);
31
31
  }
32
32
 
33
33
  console.error(fail(`unknown \`tot app\` subcommand: ${sub}`, "tot app --help"));
@@ -58,6 +58,7 @@ Options:
58
58
 
59
59
  /** Parse `tot branches` argv. Pure — unit-testable. */
60
60
  export function parseBranchesArgs(argv) {
61
+ /** @type {{ tenant: string|null, repo: string|null, staleAfterDays: number|null, mcp: string|null, identity: string|null, json: boolean, help: boolean }} */
61
62
  const a = {
62
63
  tenant: null,
63
64
  repo: null,
@@ -65,6 +65,7 @@ Options:
65
65
 
66
66
  /** Parse `tot cleanup` argv. Pure — unit-testable. */
67
67
  export function parseCleanupArgs(argv) {
68
+ /** @type {{ tenant: string|null, repo: string|null, staleAfterDays: number|null, ref: string|null, dryRun: boolean, yes: boolean, mcp: string|null, identity: string|null, help: boolean }} */
68
69
  const a = {
69
70
  tenant: null,
70
71
  repo: null,
@@ -206,7 +207,7 @@ export async function run(argv, ctx) {
206
207
 
207
208
  const planLines = planForAction({
208
209
  action: "cleanup",
209
- tenant: tenant || repo,
210
+ tenant: /** @type {string} */ (tenant || repo),
210
211
  refs: eligible.map((b) => ({ ref: b.ref, sha: b.sha, reason: b.reason })),
211
212
  });
212
213
  const { confirmed, reason } = await printPlanAndConfirm(planLines, {
@@ -54,12 +54,14 @@ import { offerSignIn } from "./login.mjs";
54
54
  import { CliError, fail, formatError } from "../errors.mjs";
55
55
  import { writeNvmrc } from "../sample.mjs";
56
56
  import { emitObstacle } from "../obstacle.mjs";
57
+ import { CREDENTIAL_HELPER, splitAuthedRemote, basicAuthExtraHeader } from "../git-credential.mjs";
57
58
 
58
59
  const execFileP = promisify(execFile);
59
60
 
60
61
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
61
62
 
62
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 }} */
63
65
  const a = {
64
66
  tenant: null,
65
67
  dir: null,
@@ -244,9 +246,9 @@ export async function run(argv, ctx) {
244
246
 
245
247
  if (res.cloned) {
246
248
  console.log(`+ cloned. HEAD: ${res.head}`);
247
- console.log(`\nYour local working clone is at ${res.dir} with an authenticated remote.`);
249
+ console.log(`\nYour local working clone is at ${res.dir}.`);
248
250
  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)`);
251
+ console.log(` (origin has no token \`tot\` mints one fresh at fetch/push time)`);
250
252
  return 0;
251
253
  }
252
254
 
@@ -323,17 +325,38 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
323
325
  }
324
326
 
325
327
  /**
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.
328
+ * git clone the authenticated remote into `dir` WITHOUT ever writing the
329
+ * live token to `.git/config` (unit u10). 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 pre-u10 shape this fixes), 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.
331
346
  */
332
347
  async function cloneRepo(gitRemote, dir, redact) {
333
348
  const git = async (cargs) => (await execFileP("git", cargs)).stdout.toString();
334
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
335
358
  try {
336
- await git(["clone", gitRemote, dir]);
359
+ await git(cloneArgs);
337
360
  } catch (e) {
338
361
  // Beacon the cockpit before we surface the error — covers this path for both
339
362
  // `tot clone` and `tot start` (which clones through here). Awaited so the
@@ -343,6 +366,12 @@ async function cloneRepo(gitRemote, dir, redact) {
343
366
  next: `check the target dir is empty and you can reach the remote, then re-run`,
344
367
  });
345
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
+ }
346
375
  const head = (await git(["-C", dir, "log", "-1", "--oneline"])).trim();
347
376
  writeNvmrc(dir); // version-manager hooks land on a supported Node on cd
348
377
  return { dir, head };
@@ -446,7 +475,7 @@ export async function cloneRepoAtPrRef(gitRemote, dir, pr, redact, deps = {}) {
446
475
  * @returns {{ message: string, next: string }|null}
447
476
  */
448
477
  export function readCredentialError(minted) {
449
- const c = minted && typeof minted === "object" && !Array.isArray(minted) ? minted : null;
478
+ const c = /** @type {any} */ (minted && typeof minted === "object" && !Array.isArray(minted) ? minted : null);
450
479
  if (c && Array.isArray(c.repos) && c.repos.some((r) => r && r.gitRemote)) return null;
451
480
  const msg =
452
481
  (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
@@ -493,7 +522,7 @@ export function readCredentialError(minted) {
493
522
  * @returns {{ message: string, next: string }|null}
494
523
  */
495
524
  export function checkoutError(checkout) {
496
- 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);
497
526
  if (c && c.gitRemote) return null; // a usable checkout — never an error
498
527
  const msg =
499
528
  (c && (c.message || (typeof c.error === "string" ? c.error : c.error?.message))) ||
@@ -536,7 +565,8 @@ export function checkoutError(checkout) {
536
565
  * @returns {Array<{ id: string, name: string, raw: any }>}
537
566
  */
538
567
  export function normalizeStores(list) {
539
- const rows = Array.isArray(list) ? list : list?.clients || list?.tenants || [];
568
+ const l = /** @type {any} */ (list);
569
+ const rows = Array.isArray(l) ? l : l?.clients || l?.tenants || [];
540
570
  if (!Array.isArray(rows)) return [];
541
571
  return rows
542
572
  .map((r) => ({
@@ -562,18 +592,19 @@ export function normalizeStores(list) {
562
592
  */
563
593
  export function storeListError(list) {
564
594
  if (list == null || typeof list !== "object" || Array.isArray(list)) return null;
595
+ const l = /** @type {any} */ (list);
565
596
  // A resolvable store array present → it succeeded, never an error.
566
- if (Array.isArray(list.clients) || Array.isArray(list.tenants)) return null;
597
+ if (Array.isArray(l.clients) || Array.isArray(l.tenants)) return null;
567
598
  const msg =
568
- list.message ||
569
- (typeof list.error === "string" ? list.error : list.error?.message) ||
599
+ l.message ||
600
+ (typeof l.error === "string" ? l.error : l.error?.message) ||
570
601
  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}"`;
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}"`;
574
605
  }
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();
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();
577
608
  return null;
578
609
  }
579
610
 
@@ -592,7 +623,7 @@ export function storeListError(list) {
592
623
  * @returns {{ brokerStatus: string|null, nextAction: string|null }}
593
624
  */
594
625
  export function brokerRemediation(list) {
595
- 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);
596
627
  const brokerStatus = c && typeof c.brokerStatus === "string" ? c.brokerStatus : null;
597
628
  const nextAction =
598
629
  c && typeof c.nextAction === "string" && c.nextAction.trim() ? c.nextAction.trim() : null;
@@ -409,7 +409,7 @@ export function printSampleBanner({ url }, mode = "sample") {
409
409
  * Exported so sample/tests can drive the selection directly.
410
410
  * @param {ReturnType<typeof parseArgs>} args
411
411
  * @param {{ client?: any }} [opts] an already-authenticated MCP client (entitled path)
412
- * @returns {Promise<{kind:"public"|"entitled",version:string,url:string,strip:number,cacheKey:string}>}
412
+ * @returns {Promise<{kind:string,version:string,url:string,strip:number,cacheKey:string,integrity:string|null}>}
413
413
  */
414
414
  export async function resolveRendererSource(args, { client } = {}) {
415
415
  if (args.sample) return resolvePublicRendererSource(args);
@@ -576,6 +576,11 @@ function compareStableAsc(a, b) {
576
576
  * override with `--renderer-version` / TOT_RUNNER_VERSION. Package/registry
577
577
  * overridable via env for testing.
578
578
  */
579
+ /**
580
+ * @param {any} args
581
+ * @param {NodeJS.ProcessEnv} [env]
582
+ * @param {{ declaredVersion?: string|null }} [opts]
583
+ */
579
584
  export async function resolvePublicRendererSource(args, env = process.env, { declaredVersion = null } = {}) {
580
585
  const pkg = env.TOT_RUNNER_PACKAGE || PUBLIC_RUNNER_PACKAGE;
581
586
  const registry = (env.TOT_NPM_REGISTRY || DEFAULT_NPM_REGISTRY).replace(/\/$/, "");
@@ -635,6 +640,7 @@ export async function resolvePublicRendererSource(args, env = process.env, { dec
635
640
  * developer entitlement (same gate as the Docker pull token). Reuses an
636
641
  * already-authenticated `client` when provided (C1/F3), else establishes its own.
637
642
  */
643
+ /** @param {any} args @param {{ client?: any }} [opts] */
638
644
  export async function resolveEntitledRendererSource(args, { client: providedClient } = {}) {
639
645
  const baseUrl = args.mcp || process.env.MCP_BASE_URL || process.env.TOT_MCP_URL || DEFAULT_MCP_URL;
640
646
  const client = providedClient || createMcpClient(baseUrl);
@@ -674,6 +680,8 @@ export async function resolveEntitledRendererSource(args, { client: providedClie
674
680
  * instead of paying for a second client.initialize()+establishSession() — same
675
681
  * pattern as ensureRegistryLogin's `providedClient`). `tot dev` standalone
676
682
  * omits it and this establishes its own, as before.
683
+ * @param {any} args
684
+ * @param {{ client?: any }} [opts]
677
685
  * @returns {Promise<string>} the cached, installed runner tree's root directory.
678
686
  */
679
687
  export async function ensureRendererArtifact(args, { client: providedClient } = {}) {
@@ -854,11 +862,11 @@ export function probeRunnerVersion(runnerDir, { timeoutMs = 4000 } = {}) {
854
862
  * @returns {Promise<string>} the runner tree's root directory (has scripts/tot-dev.mjs).
855
863
  */
856
864
  export async function ensureSampleRenderer(args, ctx, { env = process.env, cacheRoot = RENDERER_CACHE_ROOT } = {}) {
857
- const src = resolveLocalRendererSource({
865
+ const src = /** @type {any} */ (resolveLocalRendererSource({
858
866
  env,
859
867
  mode: ctx?.mode,
860
868
  repoRoot: ctx?.repoRoot,
861
- });
869
+ }));
862
870
 
863
871
  if (src.kind === "dir") {
864
872
  if (!existsSync(join(src.dir, "scripts", "tot-dev.mjs"))) {
@@ -1048,7 +1056,7 @@ export async function installRunnerTarball(
1048
1056
  // of stopping a first run at an error only `rm -rf` folklore could clear.
1049
1057
  // Bounded to one retry so a genuinely-broken source still fails loudly.
1050
1058
  for (let attempt = 1; ; attempt++) {
1051
- const archivePath = isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${attempt}.tar.gz`) : localSource;
1059
+ const archivePath = /** @type {string} */ (isUrl ? join(tmpdir(), `tot-renderer-${process.pid}-${attempt}.tar.gz`) : localSource);
1052
1060
  const stagingDir = `${runnerDir}.staging-${process.pid}`;
1053
1061
  try {
1054
1062
  if (isUrl) {
@@ -1118,7 +1126,7 @@ export async function installRunnerTarball(
1118
1126
  exitCode: 2,
1119
1127
  },
1120
1128
  );
1121
- err.permanent = true;
1129
+ /** @type {any} */ (err).permanent = true;
1122
1130
  throw err;
1123
1131
  }
1124
1132
  writeCacheMarker(runnerDir, { version, integrity: contentId });
@@ -1270,7 +1278,7 @@ async function downloadFile(url, destPath) {
1270
1278
  if (!res.ok || !res.body) {
1271
1279
  throw new Error(`download failed: HTTP ${res.status} ${res.statusText}`);
1272
1280
  }
1273
- await pipeline(Readable.fromWeb(res.body), createWriteStream(destPath));
1281
+ await pipeline(Readable.fromWeb(/** @type {any} */ (res.body)), createWriteStream(destPath));
1274
1282
  }
1275
1283
 
1276
1284
  /**
@@ -1319,7 +1327,8 @@ export function ensureCorepackPnpm(runnerDir, { logPath, spawnFn = spawnSync } =
1319
1327
  for (const args of [["enable"], ["prepare", pm, "--activate"]]) {
1320
1328
  const r = spawnFn("corepack", args, { stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"] });
1321
1329
  if (fd !== null && (r.error || r.status !== 0)) {
1322
- writeSync(fd, `[tot] corepack ${args.join(" ")} → ${r.error?.code || r.error?.message || `exit ${r.status}`}\n`);
1330
+ const rerr = /** @type {any} */ (r.error);
1331
+ writeSync(fd, `[tot] corepack ${args.join(" ")} → ${rerr?.code || rerr?.message || `exit ${r.status}`}\n`);
1323
1332
  }
1324
1333
  }
1325
1334
  } finally {
@@ -1363,6 +1372,10 @@ function spawnAsyncResult(cmd, args, opts = {}) {
1363
1372
  });
1364
1373
  }
1365
1374
 
1375
+ /**
1376
+ * @param {string} runnerDir
1377
+ * @param {{ logPath?: string, spawnFn?: (cmd: string, args: string[], opts?: any) => any }} [opts]
1378
+ */
1366
1379
  export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncResult } = {}) {
1367
1380
  const fd = logPath ? openSync(logPath, "a") : null;
1368
1381
  const installArgs = ["install", "--config.dangerouslyAllowAllBuilds=true"];
@@ -1400,7 +1413,7 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
1400
1413
  stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
1401
1414
  });
1402
1415
  if (r.status === 0) return; // installed
1403
- if (r.error?.code === "ENOENT") {
1416
+ if (/** @type {any} */ (r.error)?.code === "ENOENT") {
1404
1417
  // This launcher isn't on the machine — record it and try the next one.
1405
1418
  if (fd !== null) writeSync(fd, `[tot] ${cmd} not found (ENOENT) — trying the next launcher\n`);
1406
1419
  continue;
@@ -1425,7 +1438,7 @@ export async function runPnpmInstall(runnerDir, { logPath, spawnFn = spawnAsyncR
1425
1438
  (logPath ? `\n details: ${logPath}` : ""),
1426
1439
  { next: "install pnpm with `npm i -g pnpm` (or `corepack enable`), then re-run `tot start`" },
1427
1440
  );
1428
- err.permanent = true;
1441
+ /** @type {any} */ (err).permanent = true;
1429
1442
  throw err;
1430
1443
  } finally {
1431
1444
  if (fd !== null) closeSync(fd);
@@ -1470,6 +1483,10 @@ function pnpmFailureHint(logPath) {
1470
1483
  * fs-watch HMR. Returns a handle shaped like spawnDevContainer's, so run()'s
1471
1484
  * auto-open-browser logic works unchanged for either runtime. Exported (and
1472
1485
  * `stdio` overridable) so `tot start` can pipe the logs instead of inheriting.
1486
+ * @param {string} runnerDir
1487
+ * @param {string} workspace
1488
+ * @param {string} port
1489
+ * @param {{ stdio?: any, env?: Record<string, any> }} [opts]
1473
1490
  */
1474
1491
  export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit", env = {} } = {}) {
1475
1492
  const script = join(runnerDir, "scripts", "tot-dev.mjs");
@@ -1490,7 +1507,7 @@ export function spawnNativeDev(runnerDir, workspace, port, { stdio = "inherit",
1490
1507
  [script, "--workspace", workspace, "--port", port],
1491
1508
  { cwd: runnerDir, stdio: stdioArr, env: mergedEnv },
1492
1509
  );
1493
- const handle = { child, exited: false, done: null };
1510
+ const handle = { child, exited: false, done: /** @type {any} */ (null) };
1494
1511
  handle.done = new Promise((resolvePromise) => {
1495
1512
  child.on("exit", (code) => {
1496
1513
  handle.exited = true;
@@ -1645,9 +1662,9 @@ export async function spawnDevContainer(plan, args, { stdio = "inherit" } = {})
1645
1662
 
1646
1663
  const stdioArr =
1647
1664
  stdio === "piped" ? ["ignore", "pipe", "pipe"] : ["inherit", "inherit", "inherit"];
1648
- const child = spawn("docker", plan.dockerArgs, { stdio: stdioArr });
1665
+ const child = spawn("docker", plan.dockerArgs, { stdio: /** @type {any} */ (stdioArr) });
1649
1666
 
1650
- const handle = { child, exited: false, done: null };
1667
+ const handle = { child, exited: false, done: /** @type {any} */ (null) };
1651
1668
  handle.done = new Promise((resolvePromise) => {
1652
1669
  child.on("exit", (code) => {
1653
1670
  handle.exited = true;
@@ -1687,6 +1704,7 @@ export function isPrivateRegistryImage(image) {
1687
1704
  * its own session and overlaps this with the checkout clone instead of paying
1688
1705
  * for a second client.initialize()+establishSession() serially afterward).
1689
1706
  * `tot dev` standalone omits it and this establishes its own, as before.
1707
+ * @param {string} image @param {any} args @param {{ client?: any }} [opts]
1690
1708
  */
1691
1709
  export async function ensureRegistryLogin(image, args, { client: providedClient } = {}) {
1692
1710
  const registry = String(image).split("/")[0];
@@ -0,0 +1,180 @@
1
+ /**
2
+ * `tot git-credential` — git's own credential-helper protocol (see `git help
3
+ * gitcredentials`), implemented over the developer's cached `tot login`
4
+ * session (unit u10, workstream tot-merge-conflict-resolution-ux). `tot
5
+ * clone` configures a fresh checkout's `credential.helper` to run this (see
6
+ * ../git-credential.mjs's CREDENTIAL_HELPER), so `git fetch`/`git push`/`git
7
+ * pull` — run DIRECTLY by the developer, not just through `tot preview` —
8
+ * transparently mint a fresh forge token instead of ever needing one
9
+ * persisted in `.git/config`. This is the fix for the "the panel-prescribed
10
+ * `git fetch origin` fails even after `tot login`" dead-end: `tot login`
11
+ * only ever refreshed the CLI's OWN MCP session, never the token baked into
12
+ * a checkout's remote URL at clone time.
13
+ *
14
+ * git invokes this with ONE positional arg (`get`/`store`/`erase`) and the
15
+ * request on stdin (key=value lines, blank-line/EOF terminated). Only `get`
16
+ * does real work — this CLI never persists a forge credential of its own
17
+ * beyond the short-lived cache in ../git-credential.mjs, so `store`/`erase`
18
+ * are no-ops (git calls them after a successful/failed auth respectively; we
19
+ * just drain stdin and exit 0, the correct behavior for a stateless helper).
20
+ *
21
+ * FAILS SILENT, NEVER LOUD: `get` prints NOTHING and exits non-zero on any
22
+ * problem (not signed in, MCP unreachable, cwd isn't a recognizable tenant
23
+ * checkout) — git then falls through to its next configured helper or its
24
+ * own prompt, exactly as if this helper weren't configured. A stack trace or
25
+ * a malformed credential line here would otherwise corrupt EVERY git
26
+ * operation in the checkout. It also NEVER triggers an interactive sign-in —
27
+ * this runs as a non-interactive subprocess of `git`, so a missing session
28
+ * fails through rather than trying to open a browser mid-`git fetch`.
29
+ *
30
+ * Dependency-free (global fetch + `git`, via the same MCP client + auth
31
+ * module every other command uses).
32
+ */
33
+ import { readFileSync } from "node:fs";
34
+ import { execFileSync } from "node:child_process";
35
+ import { createMcpClient } from "../mcp.mjs";
36
+ import { establishSession } from "../auth.mjs";
37
+ import { checkoutTenant } from "./clone.mjs";
38
+ import { detectContext } from "../context.mjs";
39
+ import { repoNameFromRemote, tagFromRepoName } from "./submit.mjs";
40
+ import {
41
+ parseCredentialInput, formatCredentialOutput, splitAuthedRemote,
42
+ credentialCachePath, readCachedCredential, writeCachedCredential,
43
+ } from "../git-credential.mjs";
44
+
45
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
46
+
47
+ /** Does `remoteUrl`'s host equal `host` (case-insensitive)? An unparseable or
48
+ * missing remote URL never matches — fail closed. Exported for testing. */
49
+ export function hostMatches(remoteUrl, host) {
50
+ if (!remoteUrl || !host) return false;
51
+ try {
52
+ // scp-like remotes (git@host:owner/repo.git) have no scheme — synthesize one so URL can parse the host.
53
+ const normalized = /^[^/]+@[^/:]+:/.test(remoteUrl) ? `ssh://${remoteUrl.replace(":", "/")}` : remoteUrl;
54
+ return new URL(normalized).host.toLowerCase() === host.toLowerCase();
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ /** Read the credential request git writes to stdin — blocking is correct
61
+ * here: git writes the request then closes its end, so this returns as
62
+ * soon as it's fully sent. Never blocks on an interactive terminal (git
63
+ * NEVER attaches a TTY to a credential helper's stdin — only a human
64
+ * poking at this command directly by hand would) and never throws (an
65
+ * unreadable/absent stdin is treated as an empty request). Exported so a
66
+ * test can inject a canned request instead of touching real fd 0. */
67
+ export function readStdin() {
68
+ if (process.stdin.isTTY) return "";
69
+ try {
70
+ return readFileSync(0, "utf8");
71
+ } catch {
72
+ return "";
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Mint (or reuse a cached) forge credential for the tenant the CURRENT
78
+ * directory checks out — the same `tenant_checkout` MCP call `tot clone`
79
+ * itself uses, so the credential is exactly as push-capable. Returns null on
80
+ * ANY failure (wrong dir, not signed in, MCP unreachable) rather than
81
+ * throwing — `get` treats null as "say nothing, exit non-zero".
82
+ *
83
+ * `createClient`/`establish`/`checkout` are injected (default to the real MCP
84
+ * client + auth + clone.mjs's checkoutTenant) purely so this is testable
85
+ * without a live MCP — same DI shape as chooseChangeId's injected `mint` /
86
+ * resolveDeveloperSession's injected `fetchImpl` elsewhere in this CLI.
87
+ * SCOPED TO THE CHECKOUT'S OWN REMOTE: `expectedHost` (git's requested host,
88
+ * from the credential-helper request) is checked against the host of this
89
+ * checkout's `origin` remote before a credential is ever minted or returned
90
+ * — a `get` for any OTHER host returns null (silent fail), never handing the
91
+ * tenant's forge token to a host this checkout doesn't itself push to. This
92
+ * matters because `credential.helper` is invoked per-URL by git, and a
93
+ * globally-scoped helper (or a checkout with a submodule / unrelated remote)
94
+ * must not become a way to exfiltrate the token to an arbitrary host.
95
+ * @param {{
96
+ * env?: NodeJS.ProcessEnv, cwd?: string, expectedHost?: string|null,
97
+ * createClient?: typeof createMcpClient,
98
+ * establish?: typeof establishSession,
99
+ * checkout?: typeof checkoutTenant,
100
+ * }} [opts]
101
+ * @returns {Promise<{username:string,password:string}|null>}
102
+ */
103
+ export async function mintOrCacheCredential({
104
+ env = process.env, cwd = process.cwd(), expectedHost = null,
105
+ createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
106
+ } = {}) {
107
+ const ctx = detectContext(cwd);
108
+ if (ctx.mode !== "checkout" || !ctx.tenant) return null;
109
+
110
+ const git = (cargs) =>
111
+ execFileSync("git", ["-C", ctx.workspacePath, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
112
+ let originUrl = null;
113
+ let repoName = null;
114
+ try {
115
+ originUrl = git(["remote", "get-url", "origin"]).trim();
116
+ repoName = repoNameFromRemote(originUrl);
117
+ } catch {
118
+ /* fall through — tagFromRepoName degrades to "main" on a null repo name */
119
+ }
120
+ if (expectedHost && !hostMatches(originUrl, expectedHost)) return null;
121
+ const tag = tagFromRepoName(repoName, ctx.tenant);
122
+
123
+ const cachePath = credentialCachePath(ctx.tenant, tag, env);
124
+ const cached = readCachedCredential(cachePath);
125
+ if (cached) return { username: cached.username, password: cached.password };
126
+
127
+ const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
128
+ const client = createClient(baseUrl);
129
+ try {
130
+ await establish(client, { env });
131
+ } catch {
132
+ return null; // not signed in (or session unrefreshable) — nothing this helper can do
133
+ }
134
+ try {
135
+ const res = await checkout(client, { tenant: ctx.tenant, tag, cloneDir: null });
136
+ const cred = splitAuthedRemote(res.gitRemote || "");
137
+ if (!cred) return null;
138
+ const fresh = { username: cred.username, password: cred.token };
139
+ writeCachedCredential(cachePath, fresh);
140
+ return fresh;
141
+ } catch {
142
+ return null; // MCP unreachable / tenant_checkout refused — say nothing, exit non-zero
143
+ }
144
+ }
145
+
146
+ const USAGE = `tot git-credential — git credential-helper protocol over your \`tot login\` session
147
+
148
+ Configured automatically by \`tot clone\` (credential.helper = !tot git-credential)
149
+ in every checkout it creates — you should never need to run this by hand.
150
+ See \`git help gitcredentials\` for the protocol this implements.`;
151
+
152
+ /**
153
+ * @param {string[]} argv argv[0] is git's operation: get|store|erase
154
+ * @param {any} _ctx unused — this command derives its OWN context from cwd
155
+ * (mintOrCacheCredential), since git invokes it with the credentialed
156
+ * repo's directory as cwd, which may differ from wherever `tot` itself
157
+ * was dispatched from.
158
+ * @param {{ env?: NodeJS.ProcessEnv, cwd?: string, readStdin?: typeof readStdin }
159
+ * & Parameters<typeof mintOrCacheCredential>[0]} [opts]
160
+ */
161
+ export async function run(argv, _ctx, opts = {}) {
162
+ const { env = process.env, readStdin: read = readStdin } = opts;
163
+ const op = argv[0];
164
+ if (!op || op === "--help" || op === "-h") {
165
+ console.log(USAGE);
166
+ return op ? 0 : 2;
167
+ }
168
+ // git always writes a request to stdin, even for store/erase — drain it either way
169
+ // so the subprocess exits cleanly instead of leaving git's write blocked on a full pipe.
170
+ const request = parseCredentialInput(read());
171
+
172
+ if (op !== "get") return 0; // store/erase: stateless, nothing to persist or drop.
173
+
174
+ const cred = await mintOrCacheCredential({ ...opts, expectedHost: request.host });
175
+ if (!cred) return 1; // silent — let git fall through to its next helper / its own prompt.
176
+ process.stdout.write(formatCredentialOutput({
177
+ protocol: request.protocol, host: request.host, username: cred.username, password: cred.password,
178
+ }));
179
+ return 0;
180
+ }
@@ -196,6 +196,9 @@ export function renderReadiness({ appDomain, readiness, action }) {
196
196
  const lines = ["", ` Apex ${action === "rollback" ? "rollback" : "cutover"} readiness for ${appDomain ?? "this store"}:`];
197
197
  for (const c of readiness.checks) {
198
198
  lines.push(` ${c.ok ? "✓" : "✗"} ${c.label}${c.detail ? ` — ${c.detail}` : ""}`);
199
+ // Blocked checks carry a "how to clear this" remedy from the server gate —
200
+ // the same guidance the admin panel shows, so the two surfaces never diverge.
201
+ if (!c.ok && c.remedy) lines.push(` → ${c.remedy}`);
199
202
  }
200
203
  if (action === "connect") {
201
204
  lines.push(
@@ -217,6 +220,7 @@ export function renderReadiness({ appDomain, readiness, action }) {
217
220
  * assuming it from the dispatch. Injectable delay/attempts. Returns the last
218
221
  * normalised run status (may still be "dispatched" if CI is slow — reported honestly).
219
222
  * @param {{ get:(path:string)=>Promise<any> }} http
223
+ * @param {{ attempts?: number, delayMs?: number, sleep?: Function }} [opts]
220
224
  */
221
225
  export async function pollRunTerminal(http, { attempts = 8, delayMs = 2000, sleep } = {}) {
222
226
  const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
@@ -391,7 +395,7 @@ function cap(s) {
391
395
  * ship floor. Throws on a non-2xx with the server's error message (so the caller
392
396
  * surfaces the real refusal). `fetchImpl` is injectable for tests.
393
397
  * @param {string} base
394
- * @param {{ token:string|undefined, owner:string, capability?:string, fetchImpl?:typeof fetch }} auth
398
+ * @param {{ token?:string, owner?:string, capability?:string, fetchImpl?:typeof fetch }} [auth]
395
399
  */
396
400
  export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", fetchImpl } = {}) {
397
401
  const root = base.replace(/\/+$/, "");
@@ -476,7 +480,7 @@ export async function run(argv, ctx) {
476
480
  const http = createStorefrontHttp(base, { token, owner });
477
481
  return await runGoLive(
478
482
  http,
479
- { appDomain: owner, action: args.action, rehearsal: args.rehearsal, noOpen: args.noOpen },
483
+ { appDomain: owner, action: /** @type {any} */ (args.action), rehearsal: args.rehearsal, noOpen: args.noOpen },
480
484
  { openUrl: (u) => openBrowser(u) },
481
485
  );
482
486
  }
@@ -61,9 +61,10 @@ function parseArgs(argv) {
61
61
  * exp: number|null, revokedAt: string|null }> }}
62
62
  */
63
63
  export function normalizeGrantRows(resp) {
64
- if (resp && typeof resp === "object" && !Array.isArray(resp) && Array.isArray(resp.grants)) {
65
- const activeTenant = typeof resp.activeTenant === "string" ? resp.activeTenant : null;
66
- const rows = resp.grants
64
+ const rr = /** @type {any} */ (resp);
65
+ if (resp && typeof resp === "object" && !Array.isArray(resp) && Array.isArray(rr.grants)) {
66
+ const activeTenant = typeof rr.activeTenant === "string" ? rr.activeTenant : null;
67
+ const rows = rr.grants
67
68
  .map((g) => {
68
69
  const tenant = g?.tenant ?? g?.id ?? null;
69
70
  return {
@@ -83,7 +84,7 @@ export function normalizeGrantRows(resp) {
83
84
 
84
85
  // Fallback: client_list — store ids + environment + the selected marker only. No
85
86
  // grant detail is knowable here (grantActive/capability/tier/exp/revokedAt stay null).
86
- const clients = Array.isArray(resp) ? resp : resp?.clients || resp?.tenants || [];
87
+ const clients = Array.isArray(resp) ? resp : rr?.clients || rr?.tenants || [];
87
88
  const rows = (Array.isArray(clients) ? clients : [])
88
89
  .map((c) => ({
89
90
  tenant: c?.tenant ?? c?.id ?? c?.clientId ?? c?.appDomain ?? null,
@@ -147,6 +148,7 @@ export function formatExpiry(exp, { now = Date.now() } = {}) {
147
148
  export function describeGrantRow(row, { now = Date.now() } = {}) {
148
149
  const status = grantStatus(row, { now });
149
150
  if (status === "unknown") return "grant detail unavailable (server introspection not enabled)";
151
+ /** @type {string[]} */
150
152
  const parts = [status];
151
153
  if (row.capability) parts.push(`capability=${row.capability}`);
152
154
  if (row.tier) parts.push(`tier=${row.tier}`);
@@ -54,7 +54,7 @@ Run this when \`tot whoami\` / \`tot start\` say your identity isn't linked yet.
54
54
  * @returns {{ authUrl: string|null, pollHandle: string|null }}
55
55
  */
56
56
  export function linkBeginFields(res) {
57
- const c = res && typeof res === "object" && !Array.isArray(res) ? res : {};
57
+ const c = /** @type {any} */ (res && typeof res === "object" && !Array.isArray(res) ? res : {});
58
58
  const authUrl =
59
59
  c.authUrl ||
60
60
  c.url ||
@@ -77,7 +77,7 @@ export function linkBeginFields(res) {
77
77
  * @returns {"linked"|"pending"|string}
78
78
  */
79
79
  export function linkPollStatus(res) {
80
- const c = res && typeof res === "object" && !Array.isArray(res) ? res : {};
80
+ const c = /** @type {any} */ (res && typeof res === "object" && !Array.isArray(res) ? res : {});
81
81
  if (c.linked === true || c.done === true || c.complete === true) return "linked";
82
82
  const raw =
83
83
  (typeof c.status === "string" && c.status) || (typeof c.state === "string" && c.state) || "";
@@ -120,7 +120,7 @@ After signing in, run \`tot whoami\` to confirm, then \`tot clone\` / \`tot subm
120
120
  * without duplicating this.
121
121
  * @returns {Promise<object>} the credentials written to disk.
122
122
  */
123
- export async function loginAndCache(mcpUrl, env = process.env, { log = () => {}, device = false } = {}) {
123
+ export async function loginAndCache(mcpUrl, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}), device = false } = {}) {
124
124
  const path = defaultCredentialsPath(env);
125
125
  const prior = readCredentials(path);
126
126
  const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
@@ -196,11 +196,10 @@ export async function offerSignIn(mcpUrl, env = process.env, {
196
196
  * `log` carries the fingerprint + "waiting for approval" lines to the terminal.
197
197
  * @returns {Promise<object>} the credentials written to disk.
198
198
  */
199
- export async function redeemAndCache(mcpUrl, code, env = process.env, { log = () => {} } = {}) {
199
+ export async function redeemAndCache(mcpUrl, code, env = process.env, { log = /** @type {(m?: string) => void} */ (() => {}) } = {}) {
200
200
  const path = defaultCredentialsPath(env);
201
201
  const prior = readCredentials(path);
202
- const clientId = prior && prior.mcpUrl === mcpUrl ? prior.clientId : undefined;
203
- const creds = await rendezvousLoginFlow({ mcpUrl, clientId, code, log });
202
+ const creds = await rendezvousLoginFlow({ mcpUrl, code, log });
204
203
  const merged = mergeActivityBridge(prior, mcpUrl, creds);
205
204
  writeCredentials(path, merged);
206
205
  return merged;