UNPKG

@phren/cli

Version:

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

82 lines (81 loc) • 4.24 kB
import { git } from "./projects.js"; export class HostApiError extends Error { reason; status; body; /** `body` is the host's own JSON for a refusal (gitboy's `details`, say). */ constructor(reason, message, status = 0, body = null) { super(message); this.reason = reason; this.status = status; this.body = body; } } /** The API root for a domain: `git config --global phren.<domain>.api` when * set (a self-hosted server on another port or path), else the host's * default. Only the user's global config counts: a repository's own config is * writable by whatever runs in the checkout, and must never be able to send * a stored token to another server. */ export async function apiRoot(root, domain, fallback) { const configured = (await git(root, "config", "--global", "--get", `phren.${domain}.api`).catch(() => "")).trim(); return (configured || fallback).replace(/\/+$/, ""); } /** Resolve the exact endpoint once, so Connect can check and use the same URL. * Only the hostname/port is exposed to clients, never API URL credentials. */ export async function tokenDestination(root, host) { if (!host.domain || (host.kind !== "gitlab" && host.kind !== "gitboy")) return { api: null, apiHost: host.domain }; const api = await apiRoot(root, host.domain, `https://${host.domain}/api/${host.kind === "gitlab" ? "v4" : "v1"}`); const url = new URL(api); if (!["https:", "http:"].includes(url.protocol) || !url.host) throw new HostApiError("failed", "Invalid git host API address."); return { api, apiHost: url.host.toLowerCase() }; } export async function hostJson(url, request) { const headers = { accept: "application/json", "user-agent": "phren-hook" }; if (request.token) { if (request.scheme === "private-token") headers["PRIVATE-TOKEN"] = request.token; else headers.authorization = `Bearer ${request.token}`; } if (request.body !== undefined) headers["content-type"] = "application/json"; let response; try { response = await fetch(url, { method: request.method ?? "GET", headers, redirect: "manual", body: request.body === undefined ? undefined : JSON.stringify(request.body), signal: AbortSignal.timeout(request.timeoutMs ?? 15_000), }); } catch (error) { const timedOut = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError"); throw new HostApiError("unreachable", timedOut ? `${new URL(url).host} did not answer within ${Math.round((request.timeoutMs ?? 15_000) / 1000)} seconds.` : `Could not reach ${new URL(url).host}.`); } if (response.status >= 300 && response.status < 400) throw new HostApiError("failed", `${new URL(url).host} redirected the API call; set its API address with git config phren.<domain>.api <url>.`, response.status); const text = await response.text(); let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } if (response.status === 401) throw new HostApiError("auth", "The host refused the token: it is missing, expired or revoked.", 401); if (response.status === 403) throw new HostApiError("auth", "The token cannot read this repository; it needs read access to its code, requests and pipelines.", 403); if (response.status === 404) throw new HostApiError("not-found", "The host has no such repository, or the token cannot see it.", 404); if (response.status >= 400) { const message = json && typeof json === "object" ? json.message ?? json.error : undefined; throw new HostApiError("failed", `${new URL(url).host} answered ${response.status}${message ? `: ${typeof message === "string" ? message : JSON.stringify(message)}` : ""}`.slice(0, 500), response.status, json); } return { status: response.status, json: json, headers: response.headers }; } /** An https link, or nothing: hosts' links are shown and opened as-is. */ export function safeLink(value) { return typeof value === "string" && /^https?:\/\//.test(value) ? value.slice(0, 2048) : undefined; }