offwatch 0.5.8 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/bin/offwatch.js +7 -6
  2. package/package.json +4 -3
  3. package/src/__tests__/agent-jwt-env.test.ts +79 -0
  4. package/src/__tests__/allowed-hostname.test.ts +80 -0
  5. package/src/__tests__/auth-command-registration.test.ts +16 -0
  6. package/src/__tests__/board-auth.test.ts +53 -0
  7. package/src/__tests__/common.test.ts +98 -0
  8. package/src/__tests__/company-delete.test.ts +95 -0
  9. package/src/__tests__/company-import-export-e2e.test.ts +502 -0
  10. package/src/__tests__/company-import-url.test.ts +74 -0
  11. package/src/__tests__/company-import-zip.test.ts +44 -0
  12. package/src/__tests__/company.test.ts +599 -0
  13. package/src/__tests__/context.test.ts +70 -0
  14. package/src/__tests__/data-dir.test.ts +79 -0
  15. package/src/__tests__/doctor.test.ts +102 -0
  16. package/src/__tests__/feedback.test.ts +177 -0
  17. package/src/__tests__/helpers/embedded-postgres.ts +6 -0
  18. package/src/__tests__/helpers/zip.ts +87 -0
  19. package/src/__tests__/home-paths.test.ts +44 -0
  20. package/src/__tests__/http.test.ts +106 -0
  21. package/src/__tests__/network-bind.test.ts +62 -0
  22. package/src/__tests__/onboard.test.ts +166 -0
  23. package/src/__tests__/routines.test.ts +249 -0
  24. package/src/__tests__/telemetry.test.ts +117 -0
  25. package/src/__tests__/worktree-merge-history.test.ts +492 -0
  26. package/src/__tests__/worktree.test.ts +982 -0
  27. package/src/adapters/http/format-event.ts +4 -0
  28. package/src/adapters/http/index.ts +7 -0
  29. package/src/adapters/index.ts +2 -0
  30. package/src/adapters/process/format-event.ts +4 -0
  31. package/src/adapters/process/index.ts +7 -0
  32. package/src/adapters/registry.ts +63 -0
  33. package/src/checks/agent-jwt-secret-check.ts +40 -0
  34. package/src/checks/config-check.ts +33 -0
  35. package/src/checks/database-check.ts +59 -0
  36. package/src/checks/deployment-auth-check.ts +88 -0
  37. package/src/checks/index.ts +18 -0
  38. package/src/checks/llm-check.ts +82 -0
  39. package/src/checks/log-check.ts +30 -0
  40. package/src/checks/path-resolver.ts +1 -0
  41. package/src/checks/port-check.ts +24 -0
  42. package/src/checks/secrets-check.ts +146 -0
  43. package/src/checks/storage-check.ts +51 -0
  44. package/src/client/board-auth.ts +282 -0
  45. package/src/client/command-label.ts +4 -0
  46. package/src/client/context.ts +175 -0
  47. package/src/client/http.ts +255 -0
  48. package/src/commands/allowed-hostname.ts +40 -0
  49. package/src/commands/auth-bootstrap-ceo.ts +138 -0
  50. package/src/commands/client/activity.ts +71 -0
  51. package/src/commands/client/agent.ts +315 -0
  52. package/src/commands/client/approval.ts +259 -0
  53. package/src/commands/client/auth.ts +113 -0
  54. package/src/commands/client/common.ts +221 -0
  55. package/src/commands/client/company.ts +1578 -0
  56. package/src/commands/client/context.ts +125 -0
  57. package/src/commands/client/dashboard.ts +34 -0
  58. package/src/commands/client/feedback.ts +645 -0
  59. package/src/commands/client/issue.ts +411 -0
  60. package/src/commands/client/plugin.ts +374 -0
  61. package/src/commands/client/zip.ts +129 -0
  62. package/src/commands/configure.ts +201 -0
  63. package/src/commands/db-backup.ts +102 -0
  64. package/src/commands/doctor.ts +203 -0
  65. package/src/commands/env.ts +411 -0
  66. package/src/commands/heartbeat-run.ts +344 -0
  67. package/src/commands/onboard.ts +692 -0
  68. package/src/commands/routines.ts +352 -0
  69. package/src/commands/run.ts +216 -0
  70. package/src/commands/worktree-lib.ts +279 -0
  71. package/src/commands/worktree-merge-history-lib.ts +764 -0
  72. package/src/commands/worktree.ts +2876 -0
  73. package/src/config/data-dir.ts +48 -0
  74. package/src/config/env.ts +125 -0
  75. package/src/config/home.ts +80 -0
  76. package/src/config/hostnames.ts +26 -0
  77. package/src/config/schema.ts +30 -0
  78. package/src/config/secrets-key.ts +48 -0
  79. package/src/config/server-bind.ts +183 -0
  80. package/src/config/store.ts +120 -0
  81. package/src/index.ts +182 -0
  82. package/src/prompts/database.ts +157 -0
  83. package/src/prompts/llm.ts +43 -0
  84. package/src/prompts/logging.ts +37 -0
  85. package/src/prompts/secrets.ts +99 -0
  86. package/src/prompts/server.ts +221 -0
  87. package/src/prompts/storage.ts +146 -0
  88. package/src/telemetry.ts +49 -0
  89. package/src/utils/banner.ts +24 -0
  90. package/src/utils/net.ts +18 -0
  91. package/src/utils/path-resolver.ts +25 -0
  92. package/src/version.ts +10 -0
  93. package/lib/downloader.js +0 -112
  94. package/postinstall.js +0 -23
@@ -0,0 +1,282 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import pc from "picocolors";
5
+ import { buildCliCommandLabel } from "./command-label.js";
6
+ import { resolveDefaultCliAuthPath } from "../config/home.js";
7
+
8
+ type RequestedAccess = "board" | "instance_admin_required";
9
+
10
+ interface BoardAuthCredential {
11
+ apiBase: string;
12
+ token: string;
13
+ createdAt: string;
14
+ updatedAt: string;
15
+ userId?: string | null;
16
+ }
17
+
18
+ interface BoardAuthStore {
19
+ version: 1;
20
+ credentials: Record<string, BoardAuthCredential>;
21
+ }
22
+
23
+ interface CreateChallengeResponse {
24
+ id: string;
25
+ token: string;
26
+ boardApiToken: string;
27
+ approvalPath: string;
28
+ approvalUrl: string | null;
29
+ pollPath: string;
30
+ expiresAt: string;
31
+ suggestedPollIntervalMs: number;
32
+ }
33
+
34
+ interface ChallengeStatusResponse {
35
+ id: string;
36
+ status: "pending" | "approved" | "cancelled" | "expired";
37
+ command: string;
38
+ clientName: string | null;
39
+ requestedAccess: RequestedAccess;
40
+ requestedCompanyId: string | null;
41
+ requestedCompanyName: string | null;
42
+ approvedAt: string | null;
43
+ cancelledAt: string | null;
44
+ expiresAt: string;
45
+ approvedByUser: { id: string; name: string; email: string } | null;
46
+ }
47
+
48
+ function defaultBoardAuthStore(): BoardAuthStore {
49
+ return {
50
+ version: 1,
51
+ credentials: {},
52
+ };
53
+ }
54
+
55
+ function toStringOrNull(value: unknown): string | null {
56
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
57
+ }
58
+
59
+ function normalizeApiBase(apiBase: string): string {
60
+ return apiBase.trim().replace(/\/+$/, "");
61
+ }
62
+
63
+ export function resolveBoardAuthStorePath(overridePath?: string): string {
64
+ if (overridePath?.trim()) return path.resolve(overridePath.trim());
65
+ if (process.env.PAPERCLIP_AUTH_STORE?.trim()) return path.resolve(process.env.PAPERCLIP_AUTH_STORE.trim());
66
+ return resolveDefaultCliAuthPath();
67
+ }
68
+
69
+ export function readBoardAuthStore(storePath?: string): BoardAuthStore {
70
+ const filePath = resolveBoardAuthStorePath(storePath);
71
+ if (!fs.existsSync(filePath)) return defaultBoardAuthStore();
72
+
73
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<BoardAuthStore> | null;
74
+ const credentials = raw?.credentials && typeof raw.credentials === "object" ? raw.credentials : {};
75
+ const normalized: Record<string, BoardAuthCredential> = {};
76
+
77
+ for (const [key, value] of Object.entries(credentials)) {
78
+ if (typeof value !== "object" || value === null) continue;
79
+ const record = value as unknown as Record<string, unknown>;
80
+ const apiBase = toStringOrNull(record.apiBase);
81
+ const token = toStringOrNull(record.token);
82
+ const createdAt = toStringOrNull(record.createdAt);
83
+ const updatedAt = toStringOrNull(record.updatedAt);
84
+ if (!apiBase || !token || !createdAt || !updatedAt) continue;
85
+ normalized[normalizeApiBase(key)] = {
86
+ apiBase,
87
+ token,
88
+ createdAt,
89
+ updatedAt,
90
+ userId: toStringOrNull(record.userId),
91
+ };
92
+ }
93
+
94
+ return {
95
+ version: 1,
96
+ credentials: normalized,
97
+ };
98
+ }
99
+
100
+ export function writeBoardAuthStore(store: BoardAuthStore, storePath?: string): void {
101
+ const filePath = resolveBoardAuthStorePath(storePath);
102
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
103
+ fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
104
+ }
105
+
106
+ export function getStoredBoardCredential(apiBase: string, storePath?: string): BoardAuthCredential | null {
107
+ const store = readBoardAuthStore(storePath);
108
+ return store.credentials[normalizeApiBase(apiBase)] ?? null;
109
+ }
110
+
111
+ export function setStoredBoardCredential(input: {
112
+ apiBase: string;
113
+ token: string;
114
+ userId?: string | null;
115
+ storePath?: string;
116
+ }): BoardAuthCredential {
117
+ const normalizedApiBase = normalizeApiBase(input.apiBase);
118
+ const store = readBoardAuthStore(input.storePath);
119
+ const now = new Date().toISOString();
120
+ const existing = store.credentials[normalizedApiBase];
121
+ const credential: BoardAuthCredential = {
122
+ apiBase: normalizedApiBase,
123
+ token: input.token.trim(),
124
+ createdAt: existing?.createdAt ?? now,
125
+ updatedAt: now,
126
+ userId: input.userId ?? existing?.userId ?? null,
127
+ };
128
+ store.credentials[normalizedApiBase] = credential;
129
+ writeBoardAuthStore(store, input.storePath);
130
+ return credential;
131
+ }
132
+
133
+ export function removeStoredBoardCredential(apiBase: string, storePath?: string): boolean {
134
+ const normalizedApiBase = normalizeApiBase(apiBase);
135
+ const store = readBoardAuthStore(storePath);
136
+ if (!store.credentials[normalizedApiBase]) return false;
137
+ delete store.credentials[normalizedApiBase];
138
+ writeBoardAuthStore(store, storePath);
139
+ return true;
140
+ }
141
+
142
+ function sleep(ms: number) {
143
+ return new Promise((resolve) => setTimeout(resolve, ms));
144
+ }
145
+
146
+ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
147
+ const headers = new Headers(init?.headers ?? undefined);
148
+ if (init?.body !== undefined && !headers.has("content-type")) {
149
+ headers.set("content-type", "application/json");
150
+ }
151
+ if (!headers.has("accept")) {
152
+ headers.set("accept", "application/json");
153
+ }
154
+
155
+ const response = await fetch(url, {
156
+ ...init,
157
+ headers,
158
+ });
159
+
160
+ if (!response.ok) {
161
+ const body = await response.json().catch(() => null);
162
+ const message =
163
+ body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
164
+ ? (body as { error: string }).error
165
+ : `Request failed: ${response.status}`;
166
+ throw new Error(message);
167
+ }
168
+
169
+ return response.json() as Promise<T>;
170
+ }
171
+
172
+ export function openUrl(url: string): boolean {
173
+ const platform = process.platform;
174
+ try {
175
+ if (platform === "darwin") {
176
+ const child = spawn("open", [url], { detached: true, stdio: "ignore" });
177
+ child.unref();
178
+ return true;
179
+ }
180
+ if (platform === "win32") {
181
+ const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
182
+ child.unref();
183
+ return true;
184
+ }
185
+ const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
186
+ child.unref();
187
+ return true;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ export async function loginBoardCli(params: {
194
+ apiBase: string;
195
+ requestedAccess: RequestedAccess;
196
+ requestedCompanyId?: string | null;
197
+ clientName?: string | null;
198
+ command?: string;
199
+ storePath?: string;
200
+ print?: boolean;
201
+ }): Promise<{ token: string; approvalUrl: string; userId?: string | null }> {
202
+ const apiBase = normalizeApiBase(params.apiBase);
203
+ const createUrl = `${apiBase}/api/cli-auth/challenges`;
204
+ const command = params.command?.trim() || buildCliCommandLabel();
205
+
206
+ const challenge = await requestJson<CreateChallengeResponse>(createUrl, {
207
+ method: "POST",
208
+ body: JSON.stringify({
209
+ command,
210
+ clientName: params.clientName?.trim() || "paperclipai cli",
211
+ requestedAccess: params.requestedAccess,
212
+ requestedCompanyId: params.requestedCompanyId?.trim() || null,
213
+ }),
214
+ });
215
+
216
+ const approvalUrl = challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`;
217
+ if (params.print !== false) {
218
+ console.error(pc.bold("Board authentication required"));
219
+ console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`);
220
+ }
221
+
222
+ const opened = openUrl(approvalUrl);
223
+ if (params.print !== false && opened) {
224
+ console.error(pc.dim("Opened the approval page in your browser."));
225
+ }
226
+
227
+ const expiresAtMs = Date.parse(challenge.expiresAt);
228
+ const pollMs = Math.max(500, challenge.suggestedPollIntervalMs || 1000);
229
+
230
+ while (Number.isFinite(expiresAtMs) ? Date.now() < expiresAtMs : true) {
231
+ const status = await requestJson<ChallengeStatusResponse>(
232
+ `${apiBase}/api${challenge.pollPath}?token=${encodeURIComponent(challenge.token)}`,
233
+ );
234
+
235
+ if (status.status === "approved") {
236
+ const me = await requestJson<{ userId: string; user?: { id: string } | null }>(
237
+ `${apiBase}/api/cli-auth/me`,
238
+ {
239
+ headers: {
240
+ authorization: `Bearer ${challenge.boardApiToken}`,
241
+ },
242
+ },
243
+ );
244
+ setStoredBoardCredential({
245
+ apiBase,
246
+ token: challenge.boardApiToken,
247
+ userId: me.userId ?? me.user?.id ?? null,
248
+ storePath: params.storePath,
249
+ });
250
+ return {
251
+ token: challenge.boardApiToken,
252
+ approvalUrl,
253
+ userId: me.userId ?? me.user?.id ?? null,
254
+ };
255
+ }
256
+
257
+ if (status.status === "cancelled") {
258
+ throw new Error("CLI auth challenge was cancelled.");
259
+ }
260
+ if (status.status === "expired") {
261
+ throw new Error("CLI auth challenge expired before approval.");
262
+ }
263
+
264
+ await sleep(pollMs);
265
+ }
266
+
267
+ throw new Error("CLI auth challenge expired before approval.");
268
+ }
269
+
270
+ export async function revokeStoredBoardCredential(params: {
271
+ apiBase: string;
272
+ token: string;
273
+ }): Promise<void> {
274
+ const apiBase = normalizeApiBase(params.apiBase);
275
+ await requestJson<{ revoked: boolean }>(`${apiBase}/api/cli-auth/revoke-current`, {
276
+ method: "POST",
277
+ headers: {
278
+ authorization: `Bearer ${params.token}`,
279
+ },
280
+ body: JSON.stringify({}),
281
+ });
282
+ }
@@ -0,0 +1,4 @@
1
+ export function buildCliCommandLabel(): string {
2
+ const args = process.argv.slice(2);
3
+ return args.length > 0 ? `paperclipai ${args.join(" ")}` : "paperclipai";
4
+ }
@@ -0,0 +1,175 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { resolveDefaultContextPath } from "../config/home.js";
4
+
5
+ const DEFAULT_CONTEXT_BASENAME = "context.json";
6
+ const DEFAULT_PROFILE = "default";
7
+
8
+ export interface ClientContextProfile {
9
+ apiBase?: string;
10
+ companyId?: string;
11
+ apiKeyEnvVarName?: string;
12
+ }
13
+
14
+ export interface ClientContext {
15
+ version: 1;
16
+ currentProfile: string;
17
+ profiles: Record<string, ClientContextProfile>;
18
+ }
19
+
20
+ function findContextFileFromAncestors(startDir: string): string | null {
21
+ const absoluteStartDir = path.resolve(startDir);
22
+ let currentDir = absoluteStartDir;
23
+
24
+ while (true) {
25
+ const candidate = path.resolve(currentDir, ".paperclip", DEFAULT_CONTEXT_BASENAME);
26
+ if (fs.existsSync(candidate)) {
27
+ return candidate;
28
+ }
29
+
30
+ const nextDir = path.resolve(currentDir, "..");
31
+ if (nextDir === currentDir) break;
32
+ currentDir = nextDir;
33
+ }
34
+
35
+ return null;
36
+ }
37
+
38
+ export function resolveContextPath(overridePath?: string): string {
39
+ if (overridePath) return path.resolve(overridePath);
40
+ if (process.env.PAPERCLIP_CONTEXT) return path.resolve(process.env.PAPERCLIP_CONTEXT);
41
+ return findContextFileFromAncestors(process.cwd()) ?? resolveDefaultContextPath();
42
+ }
43
+
44
+ export function defaultClientContext(): ClientContext {
45
+ return {
46
+ version: 1,
47
+ currentProfile: DEFAULT_PROFILE,
48
+ profiles: {
49
+ [DEFAULT_PROFILE]: {},
50
+ },
51
+ };
52
+ }
53
+
54
+ function parseJson(filePath: string): unknown {
55
+ try {
56
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
57
+ } catch (err) {
58
+ throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
59
+ }
60
+ }
61
+
62
+ function toStringOrUndefined(value: unknown): string | undefined {
63
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
64
+ }
65
+
66
+ function normalizeProfile(value: unknown): ClientContextProfile {
67
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
68
+ const profile = value as Record<string, unknown>;
69
+
70
+ return {
71
+ apiBase: toStringOrUndefined(profile.apiBase),
72
+ companyId: toStringOrUndefined(profile.companyId),
73
+ apiKeyEnvVarName: toStringOrUndefined(profile.apiKeyEnvVarName),
74
+ };
75
+ }
76
+
77
+ function normalizeContext(raw: unknown): ClientContext {
78
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
79
+ return defaultClientContext();
80
+ }
81
+
82
+ const record = raw as Record<string, unknown>;
83
+ const version = record.version === 1 ? 1 : 1;
84
+ const currentProfile = toStringOrUndefined(record.currentProfile) ?? DEFAULT_PROFILE;
85
+
86
+ const rawProfiles = record.profiles;
87
+ const profiles: Record<string, ClientContextProfile> = {};
88
+
89
+ if (typeof rawProfiles === "object" && rawProfiles !== null && !Array.isArray(rawProfiles)) {
90
+ for (const [name, profile] of Object.entries(rawProfiles as Record<string, unknown>)) {
91
+ if (!name.trim()) continue;
92
+ profiles[name] = normalizeProfile(profile);
93
+ }
94
+ }
95
+
96
+ if (!profiles[currentProfile]) {
97
+ profiles[currentProfile] = {};
98
+ }
99
+
100
+ if (Object.keys(profiles).length === 0) {
101
+ profiles[DEFAULT_PROFILE] = {};
102
+ }
103
+
104
+ return {
105
+ version,
106
+ currentProfile,
107
+ profiles,
108
+ };
109
+ }
110
+
111
+ export function readContext(contextPath?: string): ClientContext {
112
+ const filePath = resolveContextPath(contextPath);
113
+ if (!fs.existsSync(filePath)) {
114
+ return defaultClientContext();
115
+ }
116
+
117
+ const raw = parseJson(filePath);
118
+ return normalizeContext(raw);
119
+ }
120
+
121
+ export function writeContext(context: ClientContext, contextPath?: string): void {
122
+ const filePath = resolveContextPath(contextPath);
123
+ const dir = path.dirname(filePath);
124
+ fs.mkdirSync(dir, { recursive: true });
125
+
126
+ const normalized = normalizeContext(context);
127
+ fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
128
+ }
129
+
130
+ export function upsertProfile(
131
+ profileName: string,
132
+ patch: Partial<ClientContextProfile>,
133
+ contextPath?: string,
134
+ ): ClientContext {
135
+ const context = readContext(contextPath);
136
+ const existing = context.profiles[profileName] ?? {};
137
+ const merged: ClientContextProfile = {
138
+ ...existing,
139
+ ...patch,
140
+ };
141
+
142
+ if (patch.apiBase !== undefined && patch.apiBase.trim().length === 0) {
143
+ delete merged.apiBase;
144
+ }
145
+ if (patch.companyId !== undefined && patch.companyId.trim().length === 0) {
146
+ delete merged.companyId;
147
+ }
148
+ if (patch.apiKeyEnvVarName !== undefined && patch.apiKeyEnvVarName.trim().length === 0) {
149
+ delete merged.apiKeyEnvVarName;
150
+ }
151
+
152
+ context.profiles[profileName] = merged;
153
+ context.currentProfile = context.currentProfile || profileName;
154
+ writeContext(context, contextPath);
155
+ return context;
156
+ }
157
+
158
+ export function setCurrentProfile(profileName: string, contextPath?: string): ClientContext {
159
+ const context = readContext(contextPath);
160
+ if (!context.profiles[profileName]) {
161
+ context.profiles[profileName] = {};
162
+ }
163
+ context.currentProfile = profileName;
164
+ writeContext(context, contextPath);
165
+ return context;
166
+ }
167
+
168
+ export function resolveProfile(
169
+ context: ClientContext,
170
+ profileName?: string,
171
+ ): { name: string; profile: ClientContextProfile } {
172
+ const name = profileName?.trim() || context.currentProfile || DEFAULT_PROFILE;
173
+ const profile = context.profiles[name] ?? {};
174
+ return { name, profile };
175
+ }
@@ -0,0 +1,255 @@
1
+ import { URL } from "node:url";
2
+
3
+ export class ApiRequestError extends Error {
4
+ status: number;
5
+ details?: unknown;
6
+ body?: unknown;
7
+
8
+ constructor(status: number, message: string, details?: unknown, body?: unknown) {
9
+ super(message);
10
+ this.status = status;
11
+ this.details = details;
12
+ this.body = body;
13
+ }
14
+ }
15
+
16
+ export class ApiConnectionError extends Error {
17
+ url: string;
18
+ method: string;
19
+ causeMessage?: string;
20
+
21
+ constructor(input: {
22
+ apiBase: string;
23
+ path: string;
24
+ method: string;
25
+ cause?: unknown;
26
+ }) {
27
+ const url = buildUrl(input.apiBase, input.path);
28
+ const causeMessage = formatConnectionCause(input.cause);
29
+ super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage }));
30
+ this.url = url;
31
+ this.method = input.method;
32
+ this.causeMessage = causeMessage;
33
+ }
34
+ }
35
+
36
+ interface RequestOptions {
37
+ ignoreNotFound?: boolean;
38
+ }
39
+
40
+ interface RecoverAuthInput {
41
+ path: string;
42
+ method: string;
43
+ error: ApiRequestError;
44
+ }
45
+
46
+ interface ApiClientOptions {
47
+ apiBase: string;
48
+ apiKey?: string;
49
+ runId?: string;
50
+ recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
51
+ }
52
+
53
+ export class PaperclipApiClient {
54
+ readonly apiBase: string;
55
+ apiKey?: string;
56
+ readonly runId?: string;
57
+ readonly recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
58
+
59
+ constructor(opts: ApiClientOptions) {
60
+ this.apiBase = opts.apiBase.replace(/\/+$/, "");
61
+ this.apiKey = opts.apiKey?.trim() || undefined;
62
+ this.runId = opts.runId?.trim() || undefined;
63
+ this.recoverAuth = opts.recoverAuth;
64
+ }
65
+
66
+ get<T>(path: string, opts?: RequestOptions): Promise<T | null> {
67
+ return this.request<T>(path, { method: "GET" }, opts);
68
+ }
69
+
70
+ post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
71
+ return this.request<T>(path, {
72
+ method: "POST",
73
+ body: body === undefined ? undefined : JSON.stringify(body),
74
+ }, opts);
75
+ }
76
+
77
+ patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
78
+ return this.request<T>(path, {
79
+ method: "PATCH",
80
+ body: body === undefined ? undefined : JSON.stringify(body),
81
+ }, opts);
82
+ }
83
+
84
+ delete<T>(path: string, opts?: RequestOptions): Promise<T | null> {
85
+ return this.request<T>(path, { method: "DELETE" }, opts);
86
+ }
87
+
88
+ setApiKey(apiKey: string | undefined) {
89
+ this.apiKey = apiKey?.trim() || undefined;
90
+ }
91
+
92
+ private async request<T>(
93
+ path: string,
94
+ init: RequestInit,
95
+ opts?: RequestOptions,
96
+ hasRetriedAuth = false,
97
+ ): Promise<T | null> {
98
+ const url = buildUrl(this.apiBase, path);
99
+ const method = String(init.method ?? "GET").toUpperCase();
100
+
101
+ const headers: Record<string, string> = {
102
+ accept: "application/json",
103
+ ...toStringRecord(init.headers),
104
+ };
105
+
106
+ if (init.body !== undefined) {
107
+ headers["content-type"] = headers["content-type"] ?? "application/json";
108
+ }
109
+
110
+ if (this.apiKey) {
111
+ headers.authorization = `Bearer ${this.apiKey}`;
112
+ }
113
+
114
+ if (this.runId) {
115
+ headers["x-paperclip-run-id"] = this.runId;
116
+ }
117
+
118
+ let response: Response;
119
+ try {
120
+ response = await fetch(url, {
121
+ ...init,
122
+ headers,
123
+ });
124
+ } catch (error) {
125
+ throw new ApiConnectionError({
126
+ apiBase: this.apiBase,
127
+ path,
128
+ method,
129
+ cause: error,
130
+ });
131
+ }
132
+
133
+ if (opts?.ignoreNotFound && response.status === 404) {
134
+ return null;
135
+ }
136
+
137
+ if (!response.ok) {
138
+ const apiError = await toApiError(response);
139
+ if (!hasRetriedAuth && this.recoverAuth) {
140
+ const recoveredToken = await this.recoverAuth({
141
+ path,
142
+ method,
143
+ error: apiError,
144
+ });
145
+ if (recoveredToken) {
146
+ this.setApiKey(recoveredToken);
147
+ return this.request<T>(path, init, opts, true);
148
+ }
149
+ }
150
+ throw apiError;
151
+ }
152
+
153
+ if (response.status === 204) {
154
+ return null;
155
+ }
156
+
157
+ const text = await response.text();
158
+ if (!text.trim()) {
159
+ return null;
160
+ }
161
+
162
+ return safeParseJson(text) as T;
163
+ }
164
+ }
165
+
166
+ function buildUrl(apiBase: string, path: string): string {
167
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
168
+ const [pathname, query] = normalizedPath.split("?");
169
+ const url = new URL(apiBase);
170
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`;
171
+ if (query) url.search = query;
172
+ return url.toString();
173
+ }
174
+
175
+ function safeParseJson(text: string): unknown {
176
+ try {
177
+ return JSON.parse(text);
178
+ } catch {
179
+ return text;
180
+ }
181
+ }
182
+
183
+ async function toApiError(response: Response): Promise<ApiRequestError> {
184
+ const text = await response.text();
185
+ const parsed = safeParseJson(text);
186
+
187
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
188
+ const body = parsed as Record<string, unknown>;
189
+ const message =
190
+ (typeof body.error === "string" && body.error.trim()) ||
191
+ (typeof body.message === "string" && body.message.trim()) ||
192
+ `Request failed with status ${response.status}`;
193
+
194
+ return new ApiRequestError(response.status, message, body.details, parsed);
195
+ }
196
+
197
+ return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
198
+ }
199
+
200
+ function buildConnectionErrorMessage(input: {
201
+ apiBase: string;
202
+ url: string;
203
+ method: string;
204
+ causeMessage?: string;
205
+ }): string {
206
+ const healthUrl = buildHealthCheckUrl(input.url);
207
+ const lines = [
208
+ "Could not reach the Paperclip API.",
209
+ "",
210
+ `Request: ${input.method} ${input.url}`,
211
+ ];
212
+ if (input.causeMessage) {
213
+ lines.push(`Cause: ${input.causeMessage}`);
214
+ }
215
+ lines.push(
216
+ "",
217
+ "This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
218
+ "",
219
+ "Try:",
220
+ "- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
221
+ `- Verify the server is reachable with \`curl ${healthUrl}\`.`,
222
+ `- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
223
+ );
224
+ return lines.join("\n");
225
+ }
226
+
227
+ function buildHealthCheckUrl(requestUrl: string): string {
228
+ const url = new URL(requestUrl);
229
+ url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`;
230
+ url.search = "";
231
+ url.hash = "";
232
+ return url.toString();
233
+ }
234
+
235
+ function formatConnectionCause(error: unknown): string | undefined {
236
+ if (!error) return undefined;
237
+ if (error instanceof Error) {
238
+ return error.message.trim() || error.name;
239
+ }
240
+ const message = String(error).trim();
241
+ return message || undefined;
242
+ }
243
+
244
+ function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
245
+ if (!headers) return {};
246
+ if (Array.isArray(headers)) {
247
+ return Object.fromEntries(headers.map(([key, value]) => [key, String(value)]));
248
+ }
249
+ if (headers instanceof Headers) {
250
+ return Object.fromEntries(headers.entries());
251
+ }
252
+ return Object.fromEntries(
253
+ Object.entries(headers).map(([key, value]) => [key, String(value)]),
254
+ );
255
+ }