UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

156 lines (155 loc) • 6.98 kB
import { createHash } from "node:crypto"; import { homeDir } from "../home-paths.js"; import { invalidatePullsForDomain } from "./git.js"; import { execFile } from "node:child_process"; import { mkdir, readFile, stat } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { atomic, bridgeRoot } from "./protocol.js"; const exec = promisify(execFile); export function gitHostsFile() { return path.join(bridgeRoot(), "git-hosts.json"); } function clean(value) { return typeof value === "string" && value.trim() ? value.trim() : undefined; } function normalDomain(domain) { return domain.trim().toLowerCase().replace(/\.$/, ""); } /** The stored tokens. A file other users can read is ignored, as apns.json is. */ async function readStore(file = gitHostsFile()) { let info; try { info = await stat(file); } catch { return null; } if (!info.isFile()) return null; if (process.platform !== "win32" && ((info.mode & 0o077) !== 0 || (process.getuid && info.uid !== process.getuid()))) return "unsafe"; try { const parsed = JSON.parse(await readFile(file, "utf8")); const hosts = {}; if (parsed.hosts && typeof parsed.hosts === "object") { for (const [domain, raw] of Object.entries(parsed.hosts)) { const entry = raw && typeof raw === "object" ? raw : {}; const token = clean(entry.token); const kind = entry.kind === "gitlab" || entry.kind === "gitboy" ? entry.kind : null; if (token && kind) hosts[normalDomain(domain)] = { kind, token, ...(clean(entry.user) ? { user: clean(entry.user) } : {}), ...(clean(entry.savedAt) ? { savedAt: clean(entry.savedAt) } : {}) }; } } return { hosts }; } catch { return null; } } /** Store (or, with an empty token, forget) one domain's token. */ export async function writeHostToken(domain, kind, token, user, file = gitHostsFile()) { const key = normalDomain(domain); if (!key || /[\s/]/.test(key)) throw new Error("That is not a host name."); const current = await readStore(file); const hosts = current && current !== "unsafe" ? { ...current.hosts } : {}; const value = clean(token); if (value) hosts[key] = { kind, token: value, ...(user ? { user } : {}), savedAt: new Date().toISOString() }; else delete hosts[key]; await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); await atomic(file, JSON.stringify({ hosts }, null, 2) + "\n", 0o600); invalidatePullsForDomain(key); } /** The environment variables each host's own tools read. GITLAB_TOKEN is * glab's; it applies to gitlab.com, or to the domain GITLAB_HOST names. * GITBOY_TOKEN applies only to the domain GITBOY_HOST names. */ function fromEnv(kind, domain, env) { if (kind === "gitlab") { const named = clean(env.GITLAB_HOST)?.replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase(); if (domain === "gitlab.com" ? !named || named === "gitlab.com" : named === domain) return clean(env.GITLAB_TOKEN) ?? clean(env.GL_TOKEN); return undefined; } // gitboy runs on any domain, so its token goes only to the one GITBOY_HOST names. const named = clean(env.GITBOY_HOST)?.replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase(); return named && named === domain ? clean(env.GITBOY_TOKEN) : undefined; } /** glab's stored token for a domain, when glab is installed and signed in. */ async function fromCli(kind, domain) { if (kind !== "gitlab") return undefined; try { const { stdout } = await exec("glab", ["config", "get", "token", "--host", domain], { timeout: 5_000, env: { ...process.env, NO_COLOR: "1", GLAB_NO_PROMPT: "1" } }); return clean(stdout); } catch { return undefined; } } export async function resolveHostToken(kind, domain, options = {}) { const key = normalDomain(domain); const env = fromEnv(kind, key, options.env ?? process.env); if (env) return { token: env, source: "environment" }; const store = await readStore(options.file); const stored = store && store !== "unsafe" ? store.hosts[key] : undefined; if (stored && stored.kind === kind) return { token: stored.token, source: "file", ...(stored.user ? { user: stored.user } : {}) }; if (options.cli !== false) { const cli = await fromCli(kind, key); if (cli) return { token: cli, source: "cli" }; } return undefined; } /** Each stored domain with its kind and who it signs in as. Never a token. */ export async function listHostTokens(file = gitHostsFile()) { const store = await readStore(file); if (store === "unsafe") return "unsafe"; return Object.entries(store?.hosts ?? {}).map(([domain, { kind, user, savedAt }]) => ({ domain, kind, ...(user ? { user } : {}), ...(savedAt ? { savedAt } : {}) })); } /** Credential fingerprint only: raw credentials never enter lookup keys. * gh logout/switch-user rewrites hosts.yml even when its token is in a keychain. */ /** The lines of gh's hosts.yml under one host key (its user, protocol and any plain token). */ export function ghHostBlock(hostsYml, domain) { const want = domain.trim().toLowerCase().replace(/\.$/, ""); const out = []; let inside = false; for (const line of hostsYml.split(/\r?\n/)) { const top = /^(\S[^:]*):\s*$/.exec(line); if (top) { inside = top[1].trim().replace(/^["']|["']$/g, "").toLowerCase() === want; if (inside) out.push(line); continue; } if (inside && (line.startsWith(" ") || line.startsWith("\t") || line.trim() === "")) out.push(line); else if (line.trim()) inside = false; } return out.join("\n").trimEnd(); } export async function hostCredentialIdentity(host) { let state = null; if (host.kind === "github") { const dir = process.env.GH_CONFIG_DIR || path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir(), ".config"), "gh"); // Only this host's block of gh's hosts.yml: signing into another host must not change it. const hosts = await readFile(path.join(dir, "hosts.yml"), "utf8").catch(() => ""); state = [ process.env.GH_TOKEN, process.env.GITHUB_TOKEN, process.env.GH_ENTERPRISE_TOKEN, process.env.GITHUB_ENTERPRISE_TOKEN, ghHostBlock(hosts, host.domain ?? "github.com"), ]; } else if (host.kind && host.domain) { const store = await readStore(); state = [await resolveHostToken(host.kind, host.domain), store && store !== "unsafe" ? store.hosts[normalDomain(host.domain)] : store]; } return createHash("sha256").update(JSON.stringify(state)).digest("hex"); }