@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
156 lines (155 loc) • 6.98 kB
JavaScript
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");
}