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