| 1 | import { CliError } from "./io.js"; |
| 2 | import { VERSION } from "./version.js"; |
| 3 | |
| 4 | export class ApiError extends CliError { |
| 5 | constructor( |
| 6 | readonly status: number, |
| 7 | message: string, |
| 8 | readonly body: unknown, |
| 9 | ) { |
| 10 | super(message); |
| 11 | } |
| 12 | } |
| 13 | |
| 14 | export interface RequestOptions { |
| 15 | body?: unknown; |
| 16 | query?: Record<string, string | number | boolean | undefined | null>; |
| 17 | /** Return the response text instead of parsing JSON. */ |
| 18 | text?: boolean; |
| 19 | /** Treat these statuses as success (e.g. 404 when probing existence). */ |
| 20 | allow?: number[]; |
| 21 | headers?: Record<string, string>; |
| 22 | } |
| 23 | |
| 24 | export interface ApiResponse<T> { |
| 25 | status: number; |
| 26 | data: T; |
| 27 | headers: Headers; |
| 28 | } |
| 29 | |
| 30 | /** Thin client for the Diffinery REST API (`/api/v3`, GitHub-shaped). */ |
| 31 | export class Api { |
| 32 | constructor( |
| 33 | readonly baseUrl: string, |
| 34 | readonly token: string | null, |
| 35 | ) {} |
| 36 | |
| 37 | url(path: string, query?: RequestOptions["query"]): string { |
| 38 | const p = /^https?:\/\//.test(path) ? path : `${this.baseUrl}${path.startsWith("/api/") || path.startsWith("/-/") || path === "/mcp" ? "" : "/api/v3"}${path.startsWith("/") ? path : `/${path}`}`; |
| 39 | const u = new URL(p); |
| 40 | for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== "") u.searchParams.set(k, String(v)); |
| 41 | return u.toString(); |
| 42 | } |
| 43 | |
| 44 | async raw<T = unknown>(method: string, path: string, o: RequestOptions = {}): Promise<ApiResponse<T>> { |
| 45 | const headers: Record<string, string> = { accept: "application/json", "user-agent": `diffinery-cli/${VERSION}`, ...o.headers }; |
| 46 | if (this.token) headers.authorization = `Bearer ${this.token}`; |
| 47 | let body: string | undefined; |
| 48 | if (o.body !== undefined) { |
| 49 | body = typeof o.body === "string" ? o.body : JSON.stringify(o.body); |
| 50 | headers["content-type"] ??= "application/json"; |
| 51 | } |
| 52 | let res: Response; |
| 53 | const url = this.url(path, o.query); |
| 54 | try { |
| 55 | res = await fetch(url, { method, headers, body }); |
| 56 | } catch (e) { |
| 57 | const cause = (e as { cause?: { code?: string; message?: string } }).cause; |
| 58 | throw new CliError(`could not reach ${new URL(url).origin}: ${cause?.code ?? cause?.message ?? (e as Error).message}`); |
| 59 | } |
| 60 | const text = await res.text(); |
| 61 | let data: unknown = text; |
| 62 | if (!o.text && text && (res.headers.get("content-type") ?? "").includes("json")) { |
| 63 | try { |
| 64 | data = JSON.parse(text); |
| 65 | } catch { |
| 66 | /* leave as text */ |
| 67 | } |
| 68 | } else if (!o.text && !text) data = null; |
| 69 | if (!res.ok && !o.allow?.includes(res.status)) { |
| 70 | const msg = data && typeof data === "object" && "message" in data ? String((data as { message: unknown }).message) : text.slice(0, 300) || res.statusText; |
| 71 | let hint = ""; |
| 72 | if (res.status === 401) hint = this.token ? " (token invalid or expired; run `diffinery login`)" : " (not logged in; run `diffinery login`)"; |
| 73 | else if (res.status === 403 && /needs the (write|admin) scope/.test(msg)) hint = ` (run \`diffinery login --scope ${/admin/.test(msg) ? "admin" : "write"}\` to sign in with it)`; |
| 74 | throw new ApiError(res.status, `${method} ${new URL(url).pathname}: ${res.status} ${msg}${hint}`, data); |
| 75 | } |
| 76 | return { status: res.status, data: data as T, headers: res.headers }; |
| 77 | } |
| 78 | |
| 79 | async get<T = unknown>(path: string, query?: RequestOptions["query"]): Promise<T> { |
| 80 | return (await this.raw<T>("GET", path, { query })).data; |
| 81 | } |
| 82 | async post<T = unknown>(path: string, body?: unknown): Promise<T> { |
| 83 | return (await this.raw<T>("POST", path, { body: body ?? {} })).data; |
| 84 | } |
| 85 | async put<T = unknown>(path: string, body?: unknown): Promise<T> { |
| 86 | return (await this.raw<T>("PUT", path, { body: body ?? {} })).data; |
| 87 | } |
| 88 | async patch<T = unknown>(path: string, body: unknown): Promise<T> { |
| 89 | return (await this.raw<T>("PATCH", path, { body })).data; |
| 90 | } |
| 91 | async delete<T = unknown>(path: string): Promise<T> { |
| 92 | return (await this.raw<T>("DELETE", path)).data; |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | export const enc = encodeURIComponent; |