UNPKG

@phren/cli

Version:

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

95 lines (94 loc) • 4.65 kB
/** * A GitHub store remote over SSH whose key GitHub refuses, on a computer * where `gh` is signed in: the store switches to HTTPS with `gh` as its * credential helper instead of failing quietly until someone looks. One * computer's store sat diverged for two days that way while gh worked. */ import { execFile } from "child_process"; import * as fs from "fs"; import * as path from "path"; import { promisify } from "util"; import { atomicWriteText, runtimeFile } from "../phren-paths.js"; import { appendSyncLog } from "./outcome.js"; const exec = promisify(execFile); /** A failed switch is retried at most this often. */ export const AUTH_HEAL_RETRY_MS = 60 * 60 * 1000; /** The HTTPS URL of a GitHub SSH remote (`git@github.com:o/r.git`, `ssh://git@github.com/o/r`), else undefined. */ export function githubHttpsUrl(remote) { const match = /^(?:ssh:\/\/)?git@github\.com[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/.exec(remote.trim()); return match ? `https://github.com/${match[1]}/${match[2]}.git` : undefined; } /** gh from PATH, else where Homebrew and Linux packages put it (a service's PATH is often bare). */ async function signedInGh() { const candidates = [ ...(process.env.PATH ?? "").split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, process.platform === "win32" ? "gh.exe" : "gh")), "/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh", ]; const gh = candidates.find((file) => { try { return fs.statSync(file).isFile(); } catch { return false; } }); if (!gh) return undefined; try { // The token itself is discarded; only whether gh has one matters. await exec(gh, ["auth", "token", "--hostname", "github.com"], { timeout: 10_000 }); return gh; } catch { return undefined; } } function readHealState(cwd) { try { const state = JSON.parse(fs.readFileSync(runtimeFile(cwd, "sync-auth-heal.json"), "utf8")); return typeof state?.remote === "string" && Number.isFinite(state.at) && typeof state.detail === "string" ? state : undefined; } catch { return undefined; } } /** * Called after a transport command failed authentication. Switches the named * remote to HTTPS through gh when that answers, and otherwise says what to do. * `git` is the unguarded runner, so the probe never re-enters the backoff. */ export async function healStoreAuth(cwd, remoteName, remote, git, now = Date.now()) { const https = githubHttpsUrl(remote); if (!https) return { healed: false, detail: "" }; const previous = readHealState(cwd); if (previous?.remote === remote && now - previous.at < AUTH_HEAL_RETRY_MS) return { healed: false, detail: previous.detail }; const fail = (detail) => { atomicWriteText(runtimeFile(cwd, "sync-auth-heal.json"), JSON.stringify({ remote, at: now, detail }) + "\n"); appendSyncLog(cwd, "auth", detail); return { healed: false, detail }; }; const gh = await signedInGh(); if (!gh) { return fail(`GitHub refused this computer's SSH key for ${remote}, and gh is not signed in here. Add the key to GitHub, or run 'gh auth login' and then 'phren doctor --fix'.`); } const helper = `!${gh.includes(" ") ? `"${gh}"` : gh} auth git-credential`; // An empty helper first drops inherited ones (a stale keychain entry would answer before gh). const probe = await git(cwd, ["-c", "credential.helper=", "-c", `credential.helper=${helper}`, "ls-remote", "--quiet", "--", https, "HEAD"]); if (!probe.ok) { return fail(`GitHub refused this computer's SSH key for ${remote}, and HTTPS through gh failed too: ${(probe.error ?? "").split("\n").find(Boolean)?.slice(0, 200) ?? "unknown error"}`); } const steps = [ ["config", "--local", "--replace-all", "credential.https://github.com.helper", ""], ["config", "--local", "--add", "credential.https://github.com.helper", helper], ["remote", "set-url", "--", remoteName, https], ]; for (const step of steps) { const result = await git(cwd, step); if (!result.ok) return fail(`Could not switch ${remoteName} to HTTPS: ${(result.error ?? "").split("\n").find(Boolean)?.slice(0, 200) ?? "git config failed"}`); } fs.rmSync(runtimeFile(cwd, "sync-auth-heal.json"), { force: true }); const detail = `GitHub refused this computer's SSH key for ${remote}; switched ${remoteName} to ${https} with gh as its credential helper.`; appendSyncLog(cwd, "auth", detail); return { healed: true, detail }; }