merge: wave 2 executor (01-02) into main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a5b8fcf9d3
commit
aa1a78d7d9
1081 files changed
+445341
No files matched your search
@@ -0,0 +1,282 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import pc from "picocolors";
|
||||
import { buildCliCommandLabel } from "./command-label.js";
|
||||
import { resolveDefaultCliAuthPath } from "../config/home.js";
|
||||
|
||||
type RequestedAccess = "board" | "instance_admin_required";
|
||||
|
||||
interface BoardAuthCredential {
|
||||
apiBase: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
interface BoardAuthStore {
|
||||
version: 1;
|
||||
credentials: Record<string, BoardAuthCredential>;
|
||||
}
|
||||
|
||||
interface CreateChallengeResponse {
|
||||
id: string;
|
||||
token: string;
|
||||
boardApiToken: string;
|
||||
approvalPath: string;
|
||||
approvalUrl: string | null;
|
||||
pollPath: string;
|
||||
expiresAt: string;
|
||||
suggestedPollIntervalMs: number;
|
||||
}
|
||||
|
||||
interface ChallengeStatusResponse {
|
||||
id: string;
|
||||
status: "pending" | "approved" | "cancelled" | "expired";
|
||||
command: string;
|
||||
clientName: string | null;
|
||||
requestedAccess: RequestedAccess;
|
||||
requestedCompanyId: string | null;
|
||||
requestedCompanyName: string | null;
|
||||
approvedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
expiresAt: string;
|
||||
approvedByUser: { id: string; name: string; email: string } | null;
|
||||
}
|
||||
|
||||
function defaultBoardAuthStore(): BoardAuthStore {
|
||||
return {
|
||||
version: 1,
|
||||
credentials: {},
|
||||
};
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function normalizeApiBase(apiBase: string): string {
|
||||
return apiBase.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function resolveBoardAuthStorePath(overridePath?: string): string {
|
||||
if (overridePath?.trim()) return path.resolve(overridePath.trim());
|
||||
if (process.env.PAPERCLIP_AUTH_STORE?.trim()) return path.resolve(process.env.PAPERCLIP_AUTH_STORE.trim());
|
||||
return resolveDefaultCliAuthPath();
|
||||
}
|
||||
|
||||
export function readBoardAuthStore(storePath?: string): BoardAuthStore {
|
||||
const filePath = resolveBoardAuthStorePath(storePath);
|
||||
if (!fs.existsSync(filePath)) return defaultBoardAuthStore();
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<BoardAuthStore> | null;
|
||||
const credentials = raw?.credentials && typeof raw.credentials === "object" ? raw.credentials : {};
|
||||
const normalized: Record<string, BoardAuthCredential> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (typeof value !== "object" || value === null) continue;
|
||||
const record = value as unknown as Record<string, unknown>;
|
||||
const apiBase = toStringOrNull(record.apiBase);
|
||||
const token = toStringOrNull(record.token);
|
||||
const createdAt = toStringOrNull(record.createdAt);
|
||||
const updatedAt = toStringOrNull(record.updatedAt);
|
||||
if (!apiBase || !token || !createdAt || !updatedAt) continue;
|
||||
normalized[normalizeApiBase(key)] = {
|
||||
apiBase,
|
||||
token,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
userId: toStringOrNull(record.userId),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
credentials: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeBoardAuthStore(store: BoardAuthStore, storePath?: string): void {
|
||||
const filePath = resolveBoardAuthStorePath(storePath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function getStoredBoardCredential(apiBase: string, storePath?: string): BoardAuthCredential | null {
|
||||
const store = readBoardAuthStore(storePath);
|
||||
return store.credentials[normalizeApiBase(apiBase)] ?? null;
|
||||
}
|
||||
|
||||
export function setStoredBoardCredential(input: {
|
||||
apiBase: string;
|
||||
token: string;
|
||||
userId?: string | null;
|
||||
storePath?: string;
|
||||
}): BoardAuthCredential {
|
||||
const normalizedApiBase = normalizeApiBase(input.apiBase);
|
||||
const store = readBoardAuthStore(input.storePath);
|
||||
const now = new Date().toISOString();
|
||||
const existing = store.credentials[normalizedApiBase];
|
||||
const credential: BoardAuthCredential = {
|
||||
apiBase: normalizedApiBase,
|
||||
token: input.token.trim(),
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
userId: input.userId ?? existing?.userId ?? null,
|
||||
};
|
||||
store.credentials[normalizedApiBase] = credential;
|
||||
writeBoardAuthStore(store, input.storePath);
|
||||
return credential;
|
||||
}
|
||||
|
||||
export function removeStoredBoardCredential(apiBase: string, storePath?: string): boolean {
|
||||
const normalizedApiBase = normalizeApiBase(apiBase);
|
||||
const store = readBoardAuthStore(storePath);
|
||||
if (!store.credentials[normalizedApiBase]) return false;
|
||||
delete store.credentials[normalizedApiBase];
|
||||
writeBoardAuthStore(store, storePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers ?? undefined);
|
||||
if (init?.body !== undefined && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
if (!headers.has("accept")) {
|
||||
headers.set("accept", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
const message =
|
||||
body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
|
||||
? (body as { error: string }).error
|
||||
: `Request failed: ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function openUrl(url: string): boolean {
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === "darwin") {
|
||||
const child = spawn("open", [url], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginBoardCli(params: {
|
||||
apiBase: string;
|
||||
requestedAccess: RequestedAccess;
|
||||
requestedCompanyId?: string | null;
|
||||
clientName?: string | null;
|
||||
command?: string;
|
||||
storePath?: string;
|
||||
print?: boolean;
|
||||
}): Promise<{ token: string; approvalUrl: string; userId?: string | null }> {
|
||||
const apiBase = normalizeApiBase(params.apiBase);
|
||||
const createUrl = `${apiBase}/api/cli-auth/challenges`;
|
||||
const command = params.command?.trim() || buildCliCommandLabel();
|
||||
|
||||
const challenge = await requestJson<CreateChallengeResponse>(createUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
command,
|
||||
clientName: params.clientName?.trim() || "paperclipai cli",
|
||||
requestedAccess: params.requestedAccess,
|
||||
requestedCompanyId: params.requestedCompanyId?.trim() || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const approvalUrl = challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`;
|
||||
if (params.print !== false) {
|
||||
console.error(pc.bold("Board authentication required"));
|
||||
console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`);
|
||||
}
|
||||
|
||||
const opened = openUrl(approvalUrl);
|
||||
if (params.print !== false && opened) {
|
||||
console.error(pc.dim("Opened the approval page in your browser."));
|
||||
}
|
||||
|
||||
const expiresAtMs = Date.parse(challenge.expiresAt);
|
||||
const pollMs = Math.max(500, challenge.suggestedPollIntervalMs || 1000);
|
||||
|
||||
while (Number.isFinite(expiresAtMs) ? Date.now() < expiresAtMs : true) {
|
||||
const status = await requestJson<ChallengeStatusResponse>(
|
||||
`${apiBase}/api${challenge.pollPath}?token=${encodeURIComponent(challenge.token)}`,
|
||||
);
|
||||
|
||||
if (status.status === "approved") {
|
||||
const me = await requestJson<{ userId: string; user?: { id: string } | null }>(
|
||||
`${apiBase}/api/cli-auth/me`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${challenge.boardApiToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
setStoredBoardCredential({
|
||||
apiBase,
|
||||
token: challenge.boardApiToken,
|
||||
userId: me.userId ?? me.user?.id ?? null,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
return {
|
||||
token: challenge.boardApiToken,
|
||||
approvalUrl,
|
||||
userId: me.userId ?? me.user?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.status === "cancelled") {
|
||||
throw new Error("CLI auth challenge was cancelled.");
|
||||
}
|
||||
if (status.status === "expired") {
|
||||
throw new Error("CLI auth challenge expired before approval.");
|
||||
}
|
||||
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
throw new Error("CLI auth challenge expired before approval.");
|
||||
}
|
||||
|
||||
export async function revokeStoredBoardCredential(params: {
|
||||
apiBase: string;
|
||||
token: string;
|
||||
}): Promise<void> {
|
||||
const apiBase = normalizeApiBase(params.apiBase);
|
||||
await requestJson<{ revoked: boolean }>(`${apiBase}/api/cli-auth/revoke-current`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${params.token}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function buildCliCommandLabel(): string {
|
||||
const args = process.argv.slice(2);
|
||||
return args.length > 0 ? `paperclipai ${args.join(" ")}` : "paperclipai";
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveDefaultContextPath } from "../config/home.js";
|
||||
|
||||
const DEFAULT_CONTEXT_BASENAME = "context.json";
|
||||
const DEFAULT_PROFILE = "default";
|
||||
|
||||
export interface ClientContextProfile {
|
||||
apiBase?: string;
|
||||
companyId?: string;
|
||||
apiKeyEnvVarName?: string;
|
||||
}
|
||||
|
||||
export interface ClientContext {
|
||||
version: 1;
|
||||
currentProfile: string;
|
||||
profiles: Record<string, ClientContextProfile>;
|
||||
}
|
||||
|
||||
function findContextFileFromAncestors(startDir: string): string | null {
|
||||
const absoluteStartDir = path.resolve(startDir);
|
||||
let currentDir = absoluteStartDir;
|
||||
|
||||
while (true) {
|
||||
const candidate = path.resolve(currentDir, ".paperclip", DEFAULT_CONTEXT_BASENAME);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const nextDir = path.resolve(currentDir, "..");
|
||||
if (nextDir === currentDir) break;
|
||||
currentDir = nextDir;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveContextPath(overridePath?: string): string {
|
||||
if (overridePath) return path.resolve(overridePath);
|
||||
if (process.env.PAPERCLIP_CONTEXT) return path.resolve(process.env.PAPERCLIP_CONTEXT);
|
||||
return findContextFileFromAncestors(process.cwd()) ?? resolveDefaultContextPath();
|
||||
}
|
||||
|
||||
export function defaultClientContext(): ClientContext {
|
||||
return {
|
||||
version: 1,
|
||||
currentProfile: DEFAULT_PROFILE,
|
||||
profiles: {
|
||||
[DEFAULT_PROFILE]: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseJson(filePath: string): unknown {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toStringOrUndefined(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeProfile(value: unknown): ClientContextProfile {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
||||
const profile = value as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
apiBase: toStringOrUndefined(profile.apiBase),
|
||||
companyId: toStringOrUndefined(profile.companyId),
|
||||
apiKeyEnvVarName: toStringOrUndefined(profile.apiKeyEnvVarName),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeContext(raw: unknown): ClientContext {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
return defaultClientContext();
|
||||
}
|
||||
|
||||
const record = raw as Record<string, unknown>;
|
||||
const version = record.version === 1 ? 1 : 1;
|
||||
const currentProfile = toStringOrUndefined(record.currentProfile) ?? DEFAULT_PROFILE;
|
||||
|
||||
const rawProfiles = record.profiles;
|
||||
const profiles: Record<string, ClientContextProfile> = {};
|
||||
|
||||
if (typeof rawProfiles === "object" && rawProfiles !== null && !Array.isArray(rawProfiles)) {
|
||||
for (const [name, profile] of Object.entries(rawProfiles as Record<string, unknown>)) {
|
||||
if (!name.trim()) continue;
|
||||
profiles[name] = normalizeProfile(profile);
|
||||
}
|
||||
}
|
||||
|
||||
if (!profiles[currentProfile]) {
|
||||
profiles[currentProfile] = {};
|
||||
}
|
||||
|
||||
if (Object.keys(profiles).length === 0) {
|
||||
profiles[DEFAULT_PROFILE] = {};
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
currentProfile,
|
||||
profiles,
|
||||
};
|
||||
}
|
||||
|
||||
export function readContext(contextPath?: string): ClientContext {
|
||||
const filePath = resolveContextPath(contextPath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return defaultClientContext();
|
||||
}
|
||||
|
||||
const raw = parseJson(filePath);
|
||||
return normalizeContext(raw);
|
||||
}
|
||||
|
||||
export function writeContext(context: ClientContext, contextPath?: string): void {
|
||||
const filePath = resolveContextPath(contextPath);
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const normalized = normalizeContext(context);
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function upsertProfile(
|
||||
profileName: string,
|
||||
patch: Partial<ClientContextProfile>,
|
||||
contextPath?: string,
|
||||
): ClientContext {
|
||||
const context = readContext(contextPath);
|
||||
const existing = context.profiles[profileName] ?? {};
|
||||
const merged: ClientContextProfile = {
|
||||
...existing,
|
||||
...patch,
|
||||
};
|
||||
|
||||
if (patch.apiBase !== undefined && patch.apiBase.trim().length === 0) {
|
||||
delete merged.apiBase;
|
||||
}
|
||||
if (patch.companyId !== undefined && patch.companyId.trim().length === 0) {
|
||||
delete merged.companyId;
|
||||
}
|
||||
if (patch.apiKeyEnvVarName !== undefined && patch.apiKeyEnvVarName.trim().length === 0) {
|
||||
delete merged.apiKeyEnvVarName;
|
||||
}
|
||||
|
||||
context.profiles[profileName] = merged;
|
||||
context.currentProfile = context.currentProfile || profileName;
|
||||
writeContext(context, contextPath);
|
||||
return context;
|
||||
}
|
||||
|
||||
export function setCurrentProfile(profileName: string, contextPath?: string): ClientContext {
|
||||
const context = readContext(contextPath);
|
||||
if (!context.profiles[profileName]) {
|
||||
context.profiles[profileName] = {};
|
||||
}
|
||||
context.currentProfile = profileName;
|
||||
writeContext(context, contextPath);
|
||||
return context;
|
||||
}
|
||||
|
||||
export function resolveProfile(
|
||||
context: ClientContext,
|
||||
profileName?: string,
|
||||
): { name: string; profile: ClientContextProfile } {
|
||||
const name = profileName?.trim() || context.currentProfile || DEFAULT_PROFILE;
|
||||
const profile = context.profiles[name] ?? {};
|
||||
return { name, profile };
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { URL } from "node:url";
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
status: number;
|
||||
details?: unknown;
|
||||
body?: unknown;
|
||||
|
||||
constructor(status: number, message: string, details?: unknown, body?: unknown) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiConnectionError extends Error {
|
||||
url: string;
|
||||
method: string;
|
||||
causeMessage?: string;
|
||||
|
||||
constructor(input: {
|
||||
apiBase: string;
|
||||
path: string;
|
||||
method: string;
|
||||
cause?: unknown;
|
||||
}) {
|
||||
const url = buildUrl(input.apiBase, input.path);
|
||||
const causeMessage = formatConnectionCause(input.cause);
|
||||
super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage }));
|
||||
this.url = url;
|
||||
this.method = input.method;
|
||||
this.causeMessage = causeMessage;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
ignoreNotFound?: boolean;
|
||||
}
|
||||
|
||||
interface RecoverAuthInput {
|
||||
path: string;
|
||||
method: string;
|
||||
error: ApiRequestError;
|
||||
}
|
||||
|
||||
interface ApiClientOptions {
|
||||
apiBase: string;
|
||||
apiKey?: string;
|
||||
runId?: string;
|
||||
recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export class PaperclipApiClient {
|
||||
readonly apiBase: string;
|
||||
apiKey?: string;
|
||||
readonly runId?: string;
|
||||
readonly recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
|
||||
|
||||
constructor(opts: ApiClientOptions) {
|
||||
this.apiBase = opts.apiBase.replace(/\/+$/, "");
|
||||
this.apiKey = opts.apiKey?.trim() || undefined;
|
||||
this.runId = opts.runId?.trim() || undefined;
|
||||
this.recoverAuth = opts.recoverAuth;
|
||||
}
|
||||
|
||||
get<T>(path: string, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, { method: "GET" }, opts);
|
||||
}
|
||||
|
||||
post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, {
|
||||
method: "POST",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}, opts);
|
||||
}
|
||||
|
||||
patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, {
|
||||
method: "PATCH",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}, opts);
|
||||
}
|
||||
|
||||
delete<T>(path: string, opts?: RequestOptions): Promise<T | null> {
|
||||
return this.request<T>(path, { method: "DELETE" }, opts);
|
||||
}
|
||||
|
||||
setApiKey(apiKey: string | undefined) {
|
||||
this.apiKey = apiKey?.trim() || undefined;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
opts?: RequestOptions,
|
||||
hasRetriedAuth = false,
|
||||
): Promise<T | null> {
|
||||
const url = buildUrl(this.apiBase, path);
|
||||
const method = String(init.method ?? "GET").toUpperCase();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/json",
|
||||
...toStringRecord(init.headers),
|
||||
};
|
||||
|
||||
if (init.body !== undefined) {
|
||||
headers["content-type"] = headers["content-type"] ?? "application/json";
|
||||
}
|
||||
|
||||
if (this.apiKey) {
|
||||
headers.authorization = `Bearer ${this.apiKey}`;
|
||||
}
|
||||
|
||||
if (this.runId) {
|
||||
headers["x-paperclip-run-id"] = this.runId;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ApiConnectionError({
|
||||
apiBase: this.apiBase,
|
||||
path,
|
||||
method,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts?.ignoreNotFound && response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const apiError = await toApiError(response);
|
||||
if (!hasRetriedAuth && this.recoverAuth) {
|
||||
const recoveredToken = await this.recoverAuth({
|
||||
path,
|
||||
method,
|
||||
error: apiError,
|
||||
});
|
||||
if (recoveredToken) {
|
||||
this.setApiKey(recoveredToken);
|
||||
return this.request<T>(path, init, opts, true);
|
||||
}
|
||||
}
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return safeParseJson(text) as T;
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(apiBase: string, path: string): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const [pathname, query] = normalizedPath.split("?");
|
||||
const url = new URL(apiBase);
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`;
|
||||
if (query) url.search = query;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function safeParseJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
async function toApiError(response: Response): Promise<ApiRequestError> {
|
||||
const text = await response.text();
|
||||
const parsed = safeParseJson(text);
|
||||
|
||||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
||||
const body = parsed as Record<string, unknown>;
|
||||
const message =
|
||||
(typeof body.error === "string" && body.error.trim()) ||
|
||||
(typeof body.message === "string" && body.message.trim()) ||
|
||||
`Request failed with status ${response.status}`;
|
||||
|
||||
return new ApiRequestError(response.status, message, body.details, parsed);
|
||||
}
|
||||
|
||||
return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
|
||||
}
|
||||
|
||||
function buildConnectionErrorMessage(input: {
|
||||
apiBase: string;
|
||||
url: string;
|
||||
method: string;
|
||||
causeMessage?: string;
|
||||
}): string {
|
||||
const healthUrl = buildHealthCheckUrl(input.url);
|
||||
const lines = [
|
||||
"Could not reach the Paperclip API.",
|
||||
"",
|
||||
`Request: ${input.method} ${input.url}`,
|
||||
];
|
||||
if (input.causeMessage) {
|
||||
lines.push(`Cause: ${input.causeMessage}`);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
|
||||
"",
|
||||
"Try:",
|
||||
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
|
||||
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
|
||||
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildHealthCheckUrl(requestUrl: string): string {
|
||||
const url = new URL(requestUrl);
|
||||
url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`;
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function formatConnectionCause(error: unknown): string | undefined {
|
||||
if (!error) return undefined;
|
||||
if (error instanceof Error) {
|
||||
return error.message.trim() || error.name;
|
||||
}
|
||||
const message = String(error).trim();
|
||||
return message || undefined;
|
||||
}
|
||||
|
||||
function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
if (Array.isArray(headers)) {
|
||||
return Object.fromEntries(headers.map(([key, value]) => [key, String(value)]));
|
||||
}
|
||||
if (headers instanceof Headers) {
|
||||
return Object.fromEntries(headers.entries());
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user