@nkmc/agent-fs 0.1.0

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 (42) hide show
  1. package/dist/chunk-7LIZT7L3.js +966 -0
  2. package/dist/index.cjs +1278 -0
  3. package/dist/index.d.cts +96 -0
  4. package/dist/index.d.ts +96 -0
  5. package/dist/index.js +419 -0
  6. package/dist/rpc-D1IHpjF_.d.cts +330 -0
  7. package/dist/rpc-D1IHpjF_.d.ts +330 -0
  8. package/dist/testing.cjs +842 -0
  9. package/dist/testing.d.cts +29 -0
  10. package/dist/testing.d.ts +29 -0
  11. package/dist/testing.js +10 -0
  12. package/package.json +25 -0
  13. package/src/agent-fs.ts +151 -0
  14. package/src/backends/http.ts +835 -0
  15. package/src/backends/memory.ts +183 -0
  16. package/src/backends/rpc.ts +456 -0
  17. package/src/index.ts +36 -0
  18. package/src/mount.ts +84 -0
  19. package/src/parser.ts +162 -0
  20. package/src/server.ts +158 -0
  21. package/src/testing.ts +3 -0
  22. package/src/types.ts +52 -0
  23. package/test/agent-fs.test.ts +325 -0
  24. package/test/http-204.test.ts +102 -0
  25. package/test/http-auth-prefix.test.ts +79 -0
  26. package/test/http-cloudflare.test.ts +533 -0
  27. package/test/http-form-encoding.test.ts +119 -0
  28. package/test/http-github.test.ts +580 -0
  29. package/test/http-listkey.test.ts +128 -0
  30. package/test/http-oauth2.test.ts +174 -0
  31. package/test/http-pagination.test.ts +200 -0
  32. package/test/http-param-styles.test.ts +98 -0
  33. package/test/http-passthrough.test.ts +282 -0
  34. package/test/http-retry.test.ts +132 -0
  35. package/test/http.test.ts +360 -0
  36. package/test/memory.test.ts +120 -0
  37. package/test/mount.test.ts +94 -0
  38. package/test/parser.test.ts +100 -0
  39. package/test/rpc-crud.test.ts +627 -0
  40. package/test/rpc-evm.test.ts +390 -0
  41. package/tsconfig.json +8 -0
  42. package/tsup.config.ts +8 -0
@@ -0,0 +1,835 @@
1
+ import type { FsBackend } from "../types.js";
2
+ import { NotFoundError } from "./memory.js";
3
+
4
+ /** Auth configuration for HTTP requests */
5
+ export type HttpAuth =
6
+ | { type: "bearer"; token: string; prefix?: string }
7
+ | { type: "api-key"; header: string; key: string }
8
+ | { type: "basic"; username: string; password: string }
9
+ | { type: "oauth2"; tokenUrl: string; clientId: string; clientSecret: string; scope?: string };
10
+
11
+ /** A resource (collection-like, from skill.md Schema section) */
12
+ export interface HttpResource {
13
+ /** Name used in filesystem path: /mount/{name}/ */
14
+ name: string;
15
+ /** API base path for this resource. Defaults to /{name} */
16
+ apiPath?: string;
17
+ /** Field name for record ID in API responses. Defaults to "id" */
18
+ idField?: string;
19
+ /** Key to extract array from list response. e.g. "result" for Cloudflare, "data" for others */
20
+ listKey?: string;
21
+ /** Schema fields (for _schema support) */
22
+ fields?: { name: string; type: string; description?: string }[];
23
+ /** Child resources (for nested paths like /zones/{id}/dns_records) */
24
+ children?: HttpResource[];
25
+
26
+ /** "tree" mode: all remaining path segments form the ID (for file-tree APIs) */
27
+ pathMode?: "tree";
28
+
29
+ /** Override HTTP method for updates. Default "PUT" */
30
+ updateMethod?: "PUT" | "PATCH";
31
+
32
+ /** Data transformation hooks */
33
+ transform?: {
34
+ /** Transform item after GET response */
35
+ read?: (data: unknown) => unknown;
36
+ /** Transform data before POST/PUT body */
37
+ write?: (data: unknown) => unknown;
38
+ /** Build DELETE body from read result */
39
+ remove?: (readResult: unknown | null) => unknown;
40
+ /** Format each list item to display string. Default: item[idField] + ".json" */
41
+ list?: (item: unknown) => string;
42
+ };
43
+
44
+ /** Auto-read before write/delete to obtain fields like SHA, ETag */
45
+ readBeforeWrite?: {
46
+ inject: (readResult: unknown, writeData: unknown) => unknown;
47
+ };
48
+ }
49
+
50
+ /** An API endpoint (invocation-like, from skill.md API section) */
51
+ export interface HttpEndpoint {
52
+ /** Name used in filesystem path: /mount/_api/{name} */
53
+ name: string;
54
+ /** HTTP method */
55
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
56
+ /** API path */
57
+ apiPath: string;
58
+ /** Description */
59
+ description?: string;
60
+ }
61
+
62
+ /** Configuration for HttpBackend */
63
+ export interface HttpBackendConfig {
64
+ /** Base URL of the API (e.g. "https://api.cloudflare.com/client/v4") */
65
+ baseUrl: string;
66
+ /** Authentication */
67
+ auth?: HttpAuth;
68
+ /** Resource definitions (from skill.md Schema section) */
69
+ resources?: HttpResource[];
70
+ /** API endpoint definitions (from skill.md API section) */
71
+ endpoints?: HttpEndpoint[];
72
+ /** Static parameters for template resolution (e.g. { accountId: "abc123" }) */
73
+ params?: Record<string, string>;
74
+ /** Body encoding: "json" (default) or "form" (application/x-www-form-urlencoded) */
75
+ bodyEncoding?: "json" | "form";
76
+
77
+ /** Custom fetch function (for testing) */
78
+ fetch?: typeof globalThis.fetch;
79
+
80
+ /** Auto-pagination for list operations */
81
+ pagination?: PaginationConfig;
82
+
83
+ /** Retry configuration for transient errors (429, 5xx) */
84
+ retry?: {
85
+ /** Max retry attempts (default 3) */
86
+ maxRetries?: number;
87
+ /** Base delay in ms for exponential backoff (default 1000) */
88
+ baseDelayMs?: number;
89
+ };
90
+ }
91
+
92
+ /** Pagination configuration — supports multiple strategies */
93
+ export type PaginationConfig =
94
+ | { type: "link-header"; maxPages?: number }
95
+ | { type: "cursor"; cursorParam: string; cursorPath: string; maxPages?: number }
96
+ | { type: "offset"; offsetParam?: string; limitParam?: string; pageSize?: number; maxPages?: number }
97
+ | { type: "page"; pageParam?: string; maxPages?: number };
98
+
99
+ /**
100
+ * HTTP-based FsBackend driven by skill.md descriptions.
101
+ *
102
+ * Filesystem mapping (flat):
103
+ * ls / → list resources + endpoints
104
+ * ls /{resource}/ → GET baseUrl/{apiPath} (list records)
105
+ * cat /{resource}/{id}.json → GET baseUrl/{apiPath}/{id} (read record)
106
+ * write /{resource}/ data → POST baseUrl/{apiPath} (create)
107
+ * write /{resource}/{id} data → PUT baseUrl/{apiPath}/{id} (update)
108
+ * rm /{resource}/{id}.json → DELETE baseUrl/{apiPath}/{id}
109
+ * grep pattern /{resource}/ → GET baseUrl/{apiPath}?q={pattern} (search)
110
+ *
111
+ * Nested resources:
112
+ * ls /{parent}/{pid}/ → list child resource directories
113
+ * ls /{parent}/{pid}/{child}/ → GET resolved nested path (list)
114
+ * cat /{parent}/{pid}/{child}/{id}.json → GET resolved nested path/{id}
115
+ *
116
+ * Endpoints:
117
+ * ls /_api/ → list available endpoints
118
+ * cat /_api/{name} → GET endpoint (for GET endpoints)
119
+ * write /_api/{name} data → POST/PUT/etc endpoint (for non-GET endpoints)
120
+ */
121
+ export class HttpBackend implements FsBackend {
122
+ private baseUrl: string;
123
+ private auth?: HttpAuth;
124
+ private resourceList: HttpResource[];
125
+ private endpoints: Map<string, HttpEndpoint>;
126
+ private params: Record<string, string>;
127
+ private _fetch: typeof globalThis.fetch;
128
+ private bodyEncoding: "json" | "form";
129
+ private pagination?: PaginationConfig & { maxPages: number };
130
+ private retryConfig: { maxRetries: number; baseDelayMs: number };
131
+ /** Cached OAuth2 access token */
132
+ private _oauth2Token?: { token: string; expiresAt: number };
133
+
134
+ constructor(config: HttpBackendConfig) {
135
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
136
+ this.auth = config.auth;
137
+ this.resourceList = config.resources ?? [];
138
+ this.endpoints = new Map(
139
+ (config.endpoints ?? []).map((e) => [e.name, e]),
140
+ );
141
+ this.params = config.params ?? {};
142
+ this._fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
143
+ this.bodyEncoding = config.bodyEncoding ?? "json";
144
+ this.retryConfig = {
145
+ maxRetries: config.retry?.maxRetries ?? 3,
146
+ baseDelayMs: config.retry?.baseDelayMs ?? 1000,
147
+ };
148
+ if (config.pagination) {
149
+ this.pagination = {
150
+ ...config.pagination,
151
+ maxPages: config.pagination.maxPages ?? 10,
152
+ };
153
+ }
154
+ }
155
+
156
+ async list(path: string): Promise<string[]> {
157
+ const parsed = this.parsePath(path);
158
+
159
+ // Root: list all top-level resources and _api
160
+ if (parsed.type === "root") {
161
+ const entries: string[] = [];
162
+ for (const r of this.resourceList) {
163
+ entries.push(r.name + "/");
164
+ }
165
+ if (this.endpoints.size > 0) {
166
+ entries.push("_api/");
167
+ }
168
+ return entries;
169
+ }
170
+
171
+ // List endpoints
172
+ if (parsed.type === "api-list") {
173
+ return Array.from(this.endpoints.values()).map(
174
+ (e) => `${e.name} [${e.method}]`,
175
+ );
176
+ }
177
+
178
+ // List records in a resource
179
+ if (parsed.type === "resource-list") {
180
+ const { resource, resolvedApiPath } = parsed;
181
+ const items = await this.fetchAllItems(resource, resolvedApiPath);
182
+ if (!Array.isArray(items)) return [];
183
+ return this.formatListItems(resource, items);
184
+ }
185
+
186
+ // Passthrough: proxy the list request directly
187
+ if (parsed.type === "passthrough") {
188
+ const resp = await this.request("GET", parsed.apiPath);
189
+ if (!resp.ok) return [];
190
+ const data = await safeJson(resp);
191
+ if (data === null) return [];
192
+ if (Array.isArray(data)) return data.map((item: any) => String(item.id ?? item.name ?? JSON.stringify(item)));
193
+ return Object.keys(data);
194
+ }
195
+
196
+ // Intermediate node with children: list child resource directories
197
+ if (parsed.type === "resource-item") {
198
+ const { resource, resolvedApiPath, id } = parsed;
199
+
200
+ // tree mode resource-item: GET the path, if array → directory listing
201
+ if (resource.pathMode === "tree") {
202
+ const resp = await this.request("GET", `${resolvedApiPath}/${id}`);
203
+ if (!resp.ok) return [];
204
+ const data = await safeJson(resp);
205
+ if (data === null) return [];
206
+ if (Array.isArray(data)) {
207
+ return this.formatListItems(resource, data);
208
+ }
209
+ return [];
210
+ }
211
+
212
+ if (resource.children && resource.children.length > 0) {
213
+ return resource.children.map((c) => c.name + "/");
214
+ }
215
+ return [];
216
+ }
217
+
218
+ return [];
219
+ }
220
+
221
+ async read(path: string): Promise<unknown> {
222
+ const parsed = this.parsePath(path);
223
+
224
+ if (parsed.type === "resource-item") {
225
+ const { resource, resolvedApiPath, id } = parsed;
226
+
227
+ // Special: _schema
228
+ if (id === "_schema") {
229
+ return {
230
+ resource: resource.name,
231
+ fields: resource.fields ?? [],
232
+ };
233
+ }
234
+
235
+ // Special: _count
236
+ if (id === "_count") {
237
+ const resp = await this.request("GET", resolvedApiPath);
238
+ const data = await safeJson(resp) ?? {};
239
+ const items = extractList(data, resource.listKey);
240
+ return { count: Array.isArray(items) ? items.length : 0 };
241
+ }
242
+
243
+ const resp = await this.request("GET", `${resolvedApiPath}/${id}`);
244
+ if (!resp.ok) throw new NotFoundError(path);
245
+ let result = await safeJson(resp);
246
+ if (resource.transform?.read) result = resource.transform.read(result);
247
+ return result;
248
+ }
249
+
250
+ // Read all records in a resource (cat /resource/)
251
+ if (parsed.type === "resource-list") {
252
+ const { resource, resolvedApiPath } = parsed;
253
+ const resp = await this.request("GET", resolvedApiPath);
254
+ const data = await safeJson(resp);
255
+ return data === null ? [] : extractList(data, resource.listKey) ?? data;
256
+ }
257
+
258
+ // Invoke a GET endpoint
259
+ if (parsed.type === "api-call") {
260
+ const endpoint = this.getEndpoint(parsed.endpoint);
261
+ const resp = await this.request(endpoint.method, endpoint.apiPath);
262
+ return safeJson(resp);
263
+ }
264
+
265
+ // Passthrough: proxy the read request directly
266
+ if (parsed.type === "passthrough") {
267
+ const resp = await this.request("GET", parsed.apiPath);
268
+ if (!resp.ok) throw new NotFoundError(path);
269
+ return safeJson(resp);
270
+ }
271
+
272
+ throw new NotFoundError(path);
273
+ }
274
+
275
+ async write(path: string, data: unknown): Promise<{ id: string }> {
276
+ const parsed = this.parsePath(path);
277
+
278
+ // Passthrough root: POST to / when no resources and no endpoints
279
+ if (parsed.type === "root" && this.resourceList.length === 0 && this.endpoints.size === 0) {
280
+ const resp = await this.request("POST", "/", data);
281
+ const result = await safeJson(resp) ?? {};
282
+ return { id: String(result.id ?? "ok") };
283
+ }
284
+
285
+ if (parsed.type === "resource-item" && parsed.id) {
286
+ // Update: PUT/PATCH /resource/{id}
287
+ const { resource, resolvedApiPath, id } = parsed;
288
+ let writeData = data;
289
+
290
+ // readBeforeWrite: auto-read to obtain fields like SHA
291
+ if (resource.readBeforeWrite) {
292
+ try {
293
+ const readResp = await this.request(
294
+ "GET",
295
+ `${resolvedApiPath}/${id}`,
296
+ );
297
+ if (readResp.ok) {
298
+ const readResult = await safeJson(readResp);
299
+ if (readResult) writeData = resource.readBeforeWrite.inject(readResult, writeData);
300
+ }
301
+ // 404 → skip inject (create scenario)
302
+ } catch {
303
+ // skip inject on error
304
+ }
305
+ }
306
+
307
+ if (resource.transform?.write) writeData = resource.transform.write(writeData);
308
+
309
+ const method = resource.updateMethod ?? "PUT";
310
+ const resp = await this.request(
311
+ method,
312
+ `${resolvedApiPath}/${id}`,
313
+ writeData,
314
+ );
315
+ if (!resp.ok) throw new NotFoundError(path);
316
+ const result = await safeJson(resp) ?? {};
317
+ const idField = resource.idField ?? "id";
318
+ return { id: String(result[idField] ?? id) };
319
+ }
320
+
321
+ if (parsed.type === "resource-list") {
322
+ // Create: POST /resource/
323
+ const { resource, resolvedApiPath } = parsed;
324
+ let writeData = data;
325
+ if (resource.transform?.write) writeData = resource.transform.write(writeData);
326
+ const resp = await this.request("POST", resolvedApiPath, writeData);
327
+ const result = await safeJson(resp) ?? {};
328
+ const idField = resource.idField ?? "id";
329
+ return { id: String(result[idField] ?? "unknown") };
330
+ }
331
+
332
+ if (parsed.type === "api-call") {
333
+ // Invoke endpoint with body
334
+ const endpoint = this.getEndpoint(parsed.endpoint);
335
+ const resp = await this.request(endpoint.method, endpoint.apiPath, data);
336
+ const result = await safeJson(resp) ?? {};
337
+ return { id: result.id ?? "ok" };
338
+ }
339
+
340
+ // Passthrough: proxy the write request directly
341
+ if (parsed.type === "passthrough") {
342
+ const method = parsed.apiPath === "/" ? "POST" : "PUT";
343
+ const resp = await this.request(method, parsed.apiPath, data);
344
+ const result = await safeJson(resp) ?? {};
345
+ return { id: String(result.id ?? "ok") };
346
+ }
347
+
348
+ throw new Error(`Cannot write to path: ${path}`);
349
+ }
350
+
351
+ async remove(path: string): Promise<void> {
352
+ const parsed = this.parsePath(path);
353
+
354
+ if (parsed.type === "resource-item" && parsed.id) {
355
+ const { resource, resolvedApiPath, id } = parsed;
356
+
357
+ let deleteBody: unknown | undefined;
358
+
359
+ // readBeforeWrite: auto-read to get fields like SHA for delete body
360
+ if (resource.readBeforeWrite) {
361
+ const readResp = await this.request(
362
+ "GET",
363
+ `${resolvedApiPath}/${id}`,
364
+ );
365
+ if (!readResp.ok) throw new NotFoundError(path);
366
+ const readResult = await safeJson(readResp);
367
+ if (resource.transform?.remove) {
368
+ deleteBody = resource.transform.remove(readResult);
369
+ }
370
+ } else if (resource.transform?.remove) {
371
+ deleteBody = resource.transform.remove(null);
372
+ }
373
+
374
+ const resp = await this.request(
375
+ "DELETE",
376
+ `${resolvedApiPath}/${id}`,
377
+ deleteBody,
378
+ );
379
+ if (!resp.ok) throw new NotFoundError(path);
380
+ return;
381
+ }
382
+
383
+ // Passthrough: proxy the delete request directly
384
+ if (parsed.type === "passthrough") {
385
+ const resp = await this.request("DELETE", parsed.apiPath);
386
+ if (!resp.ok) throw new NotFoundError(path);
387
+ return;
388
+ }
389
+
390
+ throw new Error(`Cannot remove path: ${path}`);
391
+ }
392
+
393
+ async search(path: string, pattern: string): Promise<unknown[]> {
394
+ const parsed = this.parsePath(path);
395
+
396
+ if (parsed.type === "resource-list" || parsed.type === "resource-item") {
397
+ const { resource, resolvedApiPath } = parsed;
398
+ // Try server-side search first
399
+ const resp = await this.request(
400
+ "GET",
401
+ `${resolvedApiPath}?q=${encodeURIComponent(pattern)}`,
402
+ );
403
+ const data = await safeJson(resp) ?? {};
404
+ const items = extractList(data, resource.listKey);
405
+
406
+ if (Array.isArray(items)) return items;
407
+
408
+ // Fallback: client-side filter
409
+ const allResp = await this.request("GET", resolvedApiPath);
410
+ const allData = await safeJson(allResp) ?? {};
411
+ const allItems = extractList(allData, resource.listKey);
412
+ if (!Array.isArray(allItems)) return [];
413
+ return allItems.filter((item: unknown) =>
414
+ JSON.stringify(item).includes(pattern),
415
+ );
416
+ }
417
+
418
+ // Passthrough: proxy the search request directly
419
+ if (parsed.type === "passthrough") {
420
+ const resp = await this.request("GET", `${parsed.apiPath}?q=${encodeURIComponent(pattern)}`);
421
+ const data = await safeJson(resp);
422
+ return Array.isArray(data) ? data : [];
423
+ }
424
+
425
+ return [];
426
+ }
427
+
428
+ // --- Internal helpers ---
429
+
430
+ private async request(
431
+ method: string,
432
+ apiPath: string,
433
+ body?: unknown,
434
+ absoluteUrl?: boolean,
435
+ ): Promise<Response> {
436
+ const url = absoluteUrl ? apiPath : `${this.baseUrl}${apiPath}`;
437
+ const useForm = this.bodyEncoding === "form";
438
+ const headers: Record<string, string> = {
439
+ "Content-Type": useForm ? "application/x-www-form-urlencoded" : "application/json",
440
+ Accept: "application/json",
441
+ "User-Agent": "nkmc-gateway/1.0",
442
+ };
443
+
444
+ if (this.auth) {
445
+ switch (this.auth.type) {
446
+ case "bearer": {
447
+ const prefix = this.auth.prefix ?? "Bearer";
448
+ headers["Authorization"] = `${prefix} ${this.auth.token}`;
449
+ break;
450
+ }
451
+ case "api-key":
452
+ headers[this.auth.header] = this.auth.key;
453
+ break;
454
+ case "basic":
455
+ headers["Authorization"] =
456
+ `Basic ${btoa(`${this.auth.username}:${this.auth.password}`)}`;
457
+ break;
458
+ case "oauth2": {
459
+ const token = await this.getOAuth2Token();
460
+ headers["Authorization"] = `Bearer ${token}`;
461
+ break;
462
+ }
463
+ }
464
+ }
465
+
466
+ let encodedBody: string | undefined;
467
+ if (body !== undefined) {
468
+ encodedBody = useForm ? encodeFormBody(body) : JSON.stringify(body);
469
+ }
470
+
471
+ // Retry loop for transient errors (429, 5xx)
472
+ let lastResp: Response | undefined;
473
+ for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
474
+ lastResp = await this._fetch(url, {
475
+ method,
476
+ headers,
477
+ body: encodedBody,
478
+ });
479
+
480
+ // Don't retry on success or client errors (except 429)
481
+ if (lastResp.ok || (lastResp.status >= 400 && lastResp.status < 500 && lastResp.status !== 429)) {
482
+ return lastResp;
483
+ }
484
+
485
+ // Last attempt — don't wait, just return
486
+ if (attempt === this.retryConfig.maxRetries) break;
487
+
488
+ // Calculate delay: respect Retry-After header, or exponential backoff with jitter
489
+ let delayMs: number;
490
+ const retryAfter = lastResp.headers.get("retry-after");
491
+ if (retryAfter) {
492
+ const seconds = Number(retryAfter);
493
+ delayMs = isNaN(seconds) ? this.retryConfig.baseDelayMs : seconds * 1000;
494
+ } else {
495
+ delayMs = this.retryConfig.baseDelayMs * Math.pow(2, attempt) * (0.5 + Math.random() * 0.5);
496
+ }
497
+ await sleep(delayMs);
498
+ }
499
+
500
+ return lastResp!;
501
+ }
502
+
503
+ /** Obtain (or refresh) OAuth2 access token via client_credentials grant */
504
+ private async getOAuth2Token(): Promise<string> {
505
+ if (this.auth?.type !== "oauth2") throw new Error("Not OAuth2 auth");
506
+
507
+ // Return cached token if still valid (with 30s buffer)
508
+ if (this._oauth2Token && Date.now() < this._oauth2Token.expiresAt - 30_000) {
509
+ return this._oauth2Token.token;
510
+ }
511
+
512
+ const { tokenUrl, clientId, clientSecret, scope } = this.auth;
513
+ const params = new URLSearchParams({ grant_type: "client_credentials" });
514
+ if (scope) params.set("scope", scope);
515
+
516
+ const resp = await this._fetch(tokenUrl, {
517
+ method: "POST",
518
+ headers: {
519
+ "Content-Type": "application/x-www-form-urlencoded",
520
+ Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
521
+ },
522
+ body: params.toString(),
523
+ });
524
+
525
+ if (!resp.ok) {
526
+ throw new Error(`OAuth2 token request failed: ${resp.status} ${resp.statusText}`);
527
+ }
528
+
529
+ const data = await resp.json();
530
+ const expiresIn = data.expires_in ?? 3600;
531
+ this._oauth2Token = {
532
+ token: data.access_token,
533
+ expiresAt: Date.now() + expiresIn * 1000,
534
+ };
535
+ return this._oauth2Token.token;
536
+ }
537
+
538
+ /** Parse Link header to extract rel="next" URL */
539
+ private getNextPageUrl(resp: Response): string | null {
540
+ const link = resp.headers.get("link");
541
+ if (!link) return null;
542
+ const match = link.match(/<([^>]+)>;\s*rel="next"/);
543
+ return match ? match[1] : null;
544
+ }
545
+
546
+ /** Fetch all items with optional pagination */
547
+ private async fetchAllItems(
548
+ resource: HttpResource,
549
+ apiPath: string,
550
+ ): Promise<unknown[]> {
551
+ const resp = await this.request("GET", apiPath);
552
+ const data = await safeJson(resp);
553
+ if (data === null) return [];
554
+ let items: unknown[] = extractList(data, resource.listKey);
555
+ if (!Array.isArray(items)) return [];
556
+
557
+ // Follow pagination if configured
558
+ if (this.pagination) {
559
+ const maxPages = this.pagination.maxPages;
560
+ let pages = 1;
561
+
562
+ if (this.pagination.type === "link-header") {
563
+ let nextUrl = this.getNextPageUrl(resp);
564
+ while (nextUrl && pages < maxPages) {
565
+ const nextResp = await this.request("GET", nextUrl, undefined, true);
566
+ const nextData = await safeJson(nextResp);
567
+ if (!nextData) break;
568
+ const nextItems = extractList(nextData, resource.listKey);
569
+ if (!Array.isArray(nextItems) || nextItems.length === 0) break;
570
+ items = items.concat(nextItems);
571
+ nextUrl = this.getNextPageUrl(nextResp);
572
+ pages++;
573
+ }
574
+ } else if (this.pagination.type === "cursor") {
575
+ const { cursorParam, cursorPath } = this.pagination;
576
+ let cursor = getNestedValue(data, cursorPath);
577
+ while (cursor && pages < maxPages) {
578
+ const sep = apiPath.includes("?") ? "&" : "?";
579
+ const nextResp = await this.request("GET", `${apiPath}${sep}${cursorParam}=${encodeURIComponent(String(cursor))}`);
580
+ const nextData = await safeJson(nextResp);
581
+ if (!nextData) break;
582
+ const nextItems = extractList(nextData, resource.listKey);
583
+ if (!Array.isArray(nextItems) || nextItems.length === 0) break;
584
+ items = items.concat(nextItems);
585
+ cursor = getNestedValue(nextData, cursorPath);
586
+ pages++;
587
+ }
588
+ } else if (this.pagination.type === "offset") {
589
+ const { offsetParam = "offset", limitParam = "limit", pageSize = 100 } = this.pagination;
590
+ let offset = items.length;
591
+ while (pages < maxPages) {
592
+ const sep = apiPath.includes("?") ? "&" : "?";
593
+ const nextResp = await this.request("GET", `${apiPath}${sep}${offsetParam}=${offset}&${limitParam}=${pageSize}`);
594
+ const nextData = await safeJson(nextResp);
595
+ if (!nextData) break;
596
+ const nextItems = extractList(nextData, resource.listKey);
597
+ if (!Array.isArray(nextItems) || nextItems.length === 0) break;
598
+ items = items.concat(nextItems);
599
+ offset += nextItems.length;
600
+ pages++;
601
+ }
602
+ } else if (this.pagination.type === "page") {
603
+ const { pageParam = "page" } = this.pagination;
604
+ let page = 2; // first page already fetched
605
+ while (pages < maxPages) {
606
+ const sep = apiPath.includes("?") ? "&" : "?";
607
+ const nextResp = await this.request("GET", `${apiPath}${sep}${pageParam}=${page}`);
608
+ const nextData = await safeJson(nextResp);
609
+ if (!nextData) break;
610
+ const nextItems = extractList(nextData, resource.listKey);
611
+ if (!Array.isArray(nextItems) || nextItems.length === 0) break;
612
+ items = items.concat(nextItems);
613
+ page++;
614
+ pages++;
615
+ }
616
+ }
617
+ }
618
+
619
+ return items;
620
+ }
621
+
622
+ /** Format list items using transform.list or default idField + ".json" */
623
+ private formatListItems(resource: HttpResource, items: unknown[]): string[] {
624
+ if (resource.transform?.list) {
625
+ return items.map((item) => resource.transform!.list!(item));
626
+ }
627
+ const idField = resource.idField ?? "id";
628
+ return items.map(
629
+ (item: unknown) =>
630
+ String((item as Record<string, unknown>)[idField]) + ".json",
631
+ );
632
+ }
633
+
634
+ private getEndpoint(name: string): HttpEndpoint {
635
+ const endpoint = this.endpoints.get(name);
636
+ if (!endpoint) throw new NotFoundError(`Endpoint not found: ${name}`);
637
+ return endpoint;
638
+ }
639
+
640
+ /** Replace :param and {param} placeholders in API paths with values from config.params */
641
+ private resolveTemplate(path: string): string {
642
+ return path
643
+ .replace(/:(\w+)/g, (_, key) => {
644
+ const value = this.params[key];
645
+ if (value === undefined) throw new Error(`Missing param: ${key}`);
646
+ return value;
647
+ })
648
+ .replace(/\{(\w+)\}/g, (_, key) => {
649
+ const value = this.params[key];
650
+ if (value === undefined) throw new Error(`Missing param: ${key}`);
651
+ return value;
652
+ });
653
+ }
654
+
655
+ /** Recursively resolve a filesystem path against the resource tree */
656
+ private resolveResourcePath(
657
+ parts: string[],
658
+ pos: number,
659
+ resources: HttpResource[],
660
+ parentApiPath: string,
661
+ ): ParsedPath | null {
662
+ if (pos >= parts.length) return null;
663
+
664
+ const resourceName = parts[pos];
665
+ const resource = resources.find((r) => r.name === resourceName);
666
+ if (!resource) return null;
667
+
668
+ const rawSegment = resource.apiPath ?? `/${resource.name}`;
669
+ const resolvedSegment = this.resolveTemplate(rawSegment);
670
+ const baseApiPath = parentApiPath + resolvedSegment;
671
+
672
+ const remaining = parts.length - pos - 1;
673
+
674
+ // Tree mode: all remaining segments form the ID
675
+ if (resource.pathMode === "tree") {
676
+ if (remaining === 0) {
677
+ return { type: "resource-list", resource, resolvedApiPath: baseApiPath };
678
+ }
679
+ const id = parts.slice(pos + 1).join("/");
680
+ return { type: "resource-item", resource, resolvedApiPath: baseApiPath, id };
681
+ }
682
+
683
+ // Just resource name → list
684
+ if (remaining === 0) {
685
+ return { type: "resource-list", resource, resolvedApiPath: baseApiPath };
686
+ }
687
+
688
+ // Next part is an ID (or ID.json)
689
+ const rawId = parts[pos + 1];
690
+
691
+ // If there are more parts after the id, try to match children first
692
+ if (remaining >= 2) {
693
+ if (resource.children) {
694
+ const childResult = this.resolveResourcePath(
695
+ parts,
696
+ pos + 2,
697
+ resource.children,
698
+ baseApiPath + "/" + rawId,
699
+ );
700
+ if (childResult) return childResult;
701
+ }
702
+ // Unconsumed segments remain and no children matched → return null
703
+ // so parsePath falls through to passthrough (direct proxy).
704
+ return null;
705
+ }
706
+
707
+ // resource/id → item (all segments consumed)
708
+ let id = rawId;
709
+ if (id.endsWith(".json")) id = id.slice(0, -5);
710
+ return { type: "resource-item", resource, resolvedApiPath: baseApiPath, id };
711
+ }
712
+
713
+ private parsePath(path: string): ParsedPath {
714
+ const cleaned = path.replace(/^\/+/, "").replace(/\/+$/, "");
715
+
716
+ if (!cleaned) return { type: "root" };
717
+
718
+ const parts = cleaned.split("/");
719
+
720
+ // _api/...
721
+ if (parts[0] === "_api") {
722
+ if (parts.length === 1) return { type: "api-list" };
723
+ return { type: "api-call", endpoint: parts[1] };
724
+ }
725
+
726
+ // Recursive resource resolution
727
+ const result = this.resolveResourcePath(
728
+ parts,
729
+ 0,
730
+ this.resourceList,
731
+ "",
732
+ );
733
+ if (!result) {
734
+ // Passthrough fallback: proxy unresolved paths directly to the upstream API.
735
+ // This handles multi-segment OpenAPI paths (e.g. /repos/{owner}/{repo})
736
+ // that the flat resource tree cannot model.
737
+ return { type: "passthrough", apiPath: "/" + cleaned };
738
+ }
739
+ return result;
740
+ }
741
+ }
742
+
743
+ type ParsedPath =
744
+ | { type: "root" }
745
+ | { type: "api-list" }
746
+ | { type: "api-call"; endpoint: string }
747
+ | {
748
+ type: "resource-list";
749
+ resource: HttpResource;
750
+ resolvedApiPath: string;
751
+ }
752
+ | {
753
+ type: "resource-item";
754
+ resource: HttpResource;
755
+ resolvedApiPath: string;
756
+ id: string;
757
+ }
758
+ | { type: "passthrough"; apiPath: string };
759
+
760
+ // --- Utility functions ---
761
+
762
+ /** Safely parse JSON from a Response, returning null for 204 / empty body */
763
+ export async function safeJson(resp: Response): Promise<any> {
764
+ if (resp.status === 204) return null;
765
+ const text = await resp.text();
766
+ if (!text || !text.trim()) return null;
767
+ try {
768
+ return JSON.parse(text);
769
+ } catch {
770
+ return null;
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Extract an array from a response object.
776
+ * If listKey is set, use it. Otherwise auto-detect the best array-valued property.
777
+ * Prefers non-empty arrays over empty ones.
778
+ */
779
+ export function extractList(data: any, listKey?: string): unknown[] | any {
780
+ if (Array.isArray(data)) return data;
781
+ if (typeof data !== "object" || data === null) return data;
782
+ // Explicit listKey
783
+ if (listKey) return data[listKey];
784
+ // Auto-detect: prefer the first non-empty array, fall back to first empty array
785
+ let firstEmpty: unknown[] | null = null;
786
+ for (const value of Object.values(data)) {
787
+ if (Array.isArray(value)) {
788
+ if (value.length > 0) return value;
789
+ if (!firstEmpty) firstEmpty = value;
790
+ }
791
+ }
792
+ if (firstEmpty) return firstEmpty;
793
+ return data;
794
+ }
795
+
796
+ /** Resolve a dot-separated path to a nested value (e.g. "paging.next_cursor") */
797
+ export function getNestedValue(obj: any, path: string): unknown {
798
+ let current = obj;
799
+ for (const key of path.split(".")) {
800
+ if (current === null || current === undefined || typeof current !== "object") return undefined;
801
+ current = current[key];
802
+ }
803
+ return current;
804
+ }
805
+
806
+ function sleep(ms: number): Promise<void> {
807
+ return new Promise((resolve) => setTimeout(resolve, ms));
808
+ }
809
+
810
+ /**
811
+ * Encode an object as application/x-www-form-urlencoded.
812
+ * Supports nested objects with Stripe-style bracket notation: metadata[key]=value
813
+ */
814
+ export function encodeFormBody(data: unknown, prefix?: string): string {
815
+ if (data === null || data === undefined) return "";
816
+ if (typeof data !== "object") {
817
+ return prefix ? `${encodeURIComponent(prefix)}=${encodeURIComponent(String(data))}` : "";
818
+ }
819
+ const parts: string[] = [];
820
+ const obj = data as Record<string, unknown>;
821
+ for (const [key, value] of Object.entries(obj)) {
822
+ const fullKey = prefix ? `${prefix}[${key}]` : key;
823
+ if (Array.isArray(value)) {
824
+ for (const item of value) {
825
+ parts.push(`${encodeURIComponent(`${fullKey}[]`)}=${encodeURIComponent(String(item))}`);
826
+ }
827
+ } else if (value !== null && typeof value === "object") {
828
+ const nested = encodeFormBody(value, fullKey);
829
+ if (nested) parts.push(nested);
830
+ } else if (value !== undefined) {
831
+ parts.push(`${encodeURIComponent(fullKey)}=${encodeURIComponent(String(value))}`);
832
+ }
833
+ }
834
+ return parts.join("&");
835
+ }