From 64793980fee3d0a711224c8a2c02cd88151dc8ed Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 24 Jan 2026 21:56:28 -0500 Subject: [PATCH 1/2] feat: specli --- AGENTS.npm.md | 2 +- README.md | 2 +- package.json | 1 + pnpm-lock.yaml | 183 ++++++++++- src/commands/registry.ts | 10 +- src/commands/specli/specli.test.ts | 351 ++++++++++++++++++++ src/commands/specli/specli.ts | 503 +++++++++++++++++++++++++++++ 7 files changed, 1045 insertions(+), 7 deletions(-) create mode 100644 src/commands/specli/specli.test.ts create mode 100644 src/commands/specli/specli.ts diff --git a/AGENTS.npm.md b/AGENTS.npm.md index 5672a6ab..ad844d86 100644 --- a/AGENTS.npm.md +++ b/AGENTS.npm.md @@ -76,7 +76,7 @@ const result = await bash.exec("cat input.txt | grep pattern"); **File operations**: `basename`, `chmod`, `cp`, `dirname`, `du`, `file`, `find`, `ln`, `ls`, `mkdir`, `mv`, `od`, `pwd`, `readlink`, `rm`, `rmdir`, `split`, `stat`, `touch`, `tree` -**Utilities**: `alias`, `base64`, `bash`, `clear`, `curl`, `date`, `diff`, `echo`, `env`, `expr`, `false`, `gzip`, `gunzip`, `help`, `history`, `hostname`, `html-to-markdown`, `md5sum`, `printenv`, `printf`, `seq`, `sh`, `sha1sum`, `sha256sum`, `sleep`, `tar`, `tee`, `time`, `timeout`, `true`, `unalias`, `which`, `whoami`, `zcat` +**Utilities**: `alias`, `base64`, `bash`, `clear`, `curl`, `date`, `diff`, `echo`, `env`, `expr`, `false`, `gzip`, `gunzip`, `help`, `history`, `hostname`, `html-to-markdown`, `md5sum`, `printenv`, `printf`, `seq`, `sh`, `sha1sum`, `sha256sum`, `sleep`, `specli`, `tar`, `tee`, `time`, `timeout`, `true`, `unalias`, `which`, `whoami`, `zcat` All commands support `--help` for usage details. diff --git a/README.md b/README.md index 0b1d5e65..7491ebb2 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,7 @@ pnpm shell --no-network ### Network Commands -`curl`, `html-to-markdown` +`curl`, `html-to-markdown`, `specli` All commands support `--help` for usage information. diff --git a/package.json b/package.json index 25604bc8..ba861c6c 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "modern-tar": "^0.7.3", "papaparse": "^5.5.3", "smol-toml": "^1.6.0", + "specli": "0.0.39", "sprintf-js": "^1.1.3", "sql.js": "^1.13.0", "turndown": "^7.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d268ea2a..f803fb9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ dependencies: smol-toml: specifier: ^1.6.0 version: 1.6.0 + specli: + specifier: 0.0.39 + version: 0.0.39(ai@6.0.49)(zod@4.2.1) sprintf-js: specifier: ^1.1.3 version: 1.1.3 @@ -90,6 +93,68 @@ devDependencies: packages: + /@ai-sdk/gateway@3.0.22(zod@4.2.1): + resolution: {integrity: sha512-NgnlY73JNuooACHqUIz5uMOEWvqR1MMVbb2soGLMozLY1fgwEIF5iJFDAGa5/YArlzw2ATVU7zQu7HkR/FUjgA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + dependencies: + '@ai-sdk/provider': 3.0.5 + '@ai-sdk/provider-utils': 4.0.9(zod@4.2.1) + '@vercel/oidc': 3.1.0 + zod: 4.2.1 + dev: false + + /@ai-sdk/provider-utils@4.0.9(zod@4.2.1): + resolution: {integrity: sha512-bB4r6nfhBOpmoS9mePxjRoCy+LnzP3AfhyMGCkGL4Mn9clVNlqEeKj26zEKEtB6yoSVcT1IQ0Zh9fytwMCDnow==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + dependencies: + '@ai-sdk/provider': 3.0.5 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.2.1 + dev: false + + /@ai-sdk/provider@3.0.5: + resolution: {integrity: sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==} + engines: {node: '>=18'} + dependencies: + json-schema: 0.4.0 + dev: false + + /@apidevtools/json-schema-ref-parser@14.0.1: + resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} + engines: {node: '>= 16'} + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.1.1 + dev: false + + /@apidevtools/openapi-schemas@2.1.0: + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + dev: false + + /@apidevtools/swagger-methods@3.0.2: + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + dev: false + + /@apidevtools/swagger-parser@12.1.0(openapi-types@12.1.3): + resolution: {integrity: sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==} + peerDependencies: + openapi-types: '>=7' + dependencies: + '@apidevtools/json-schema-ref-parser': 14.0.1 + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + ajv: 8.17.1 + ajv-draft-04: 1.0.0(ajv@8.17.1) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + dev: false + /@biomejs/biome@2.3.10: resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==} engines: {node: '>=14.21.3'} @@ -500,6 +565,11 @@ packages: fastq: 1.20.1 dev: true + /@opentelemetry/api@1.9.0: + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + dev: false + /@oxc-resolver/binding-android-arm-eabi@11.16.2: resolution: {integrity: sha512-lVJbvydLQIDZHKUb6Zs9Rq80QVTQ9xdCQE30eC9/cjg4wsMoEOg65QZPymUAIVJotpUAWJD0XYcwE7ugfxx5kQ==} cpu: [arm] @@ -840,7 +910,6 @@ packages: /@standard-schema/spec@1.1.0: resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - dev: true /@tokenizer/inflate@0.4.1: resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} @@ -887,6 +956,10 @@ packages: resolution: {integrity: sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==} dev: true + /@types/json-schema@7.0.15: + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + dev: false + /@types/node@25.0.3: resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} dependencies: @@ -914,6 +987,11 @@ packages: resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} dev: true + /@vercel/oidc@3.1.0: + resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} + engines: {node: '>= 20'} + dev: false + /@vitest/expect@4.0.16: resolution: {integrity: sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==} dependencies: @@ -974,6 +1052,50 @@ packages: tinyrainbow: 3.0.3 dev: true + /ai@6.0.49(zod@4.2.1): + resolution: {integrity: sha512-LABniBX/0R6Tv+iUK5keUZhZLaZUe4YjP5M2rZ4wAdZ8iKV3EfTAoJxuL1aaWTSJKIilKa9QUEkCgnp89/32bw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + dependencies: + '@ai-sdk/gateway': 3.0.22(zod@4.2.1) + '@ai-sdk/provider': 3.0.5 + '@ai-sdk/provider-utils': 4.0.9(zod@4.2.1) + '@opentelemetry/api': 1.9.0 + zod: 4.2.1 + dev: false + + /ajv-draft-04@1.0.0(ajv@8.17.1): + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.17.1 + dev: false + + /ajv-formats@3.0.1(ajv@8.17.1): + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.17.1 + dev: false + + /ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + dev: false + /amdefine@1.0.1: resolution: {integrity: sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==} engines: {node: '>=0.4.2'} @@ -981,7 +1103,6 @@ packages: /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true /assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} @@ -1014,6 +1135,10 @@ packages: ieee754: 1.2.1 dev: false + /call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + dev: false + /chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1023,6 +1148,11 @@ packages: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} dev: false + /commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + dev: false + /commander@2.8.1: resolution: {integrity: sha512-+pJLBFVk+9ZZdlAOB5WuIElVPPth47hILFkmGym57aq8kwxsowvByvB0DHs1vQAhyMZzdcpTtF0VDKGkSDR4ZQ==} engines: {node: '>= 0.6.x'} @@ -1122,6 +1252,11 @@ packages: '@types/estree': 1.0.8 dev: true + /eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + dev: false + /expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -1132,6 +1267,10 @@ packages: engines: {node: '>=12.0.0'} dev: true + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: false + /fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1143,6 +1282,10 @@ packages: micromatch: 4.0.8 dev: true + /fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + dev: false + /fast-xml-parser@5.3.3: resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==} hasBin: true @@ -1272,7 +1415,14 @@ packages: hasBin: true dependencies: argparse: 2.0.1 - dev: true + + /json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + dev: false + + /json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + dev: false /knip@5.77.4(@types/node@25.0.3)(typescript@5.9.3): resolution: {integrity: sha512-CmRd3UabOBqA4lDUAMA8CJeepIoQPD2qRqq0wCnLz9Z3FTlG1iucZ7puwe+i3zV0gUaIWVYgC8cXoDMZEC+DyA==} @@ -1394,6 +1544,10 @@ packages: wrappy: 1.0.2 dev: false + /openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + dev: false + /oxc-resolver@11.16.2: resolution: {integrity: sha512-Uy76u47vwhhF7VAmVY61Srn+ouiOobf45MU9vGct9GD2ARy6hKoqEElyHDB0L+4JOM6VLuZ431KiLwyjI/A21g==} optionalDependencies: @@ -1499,6 +1653,11 @@ packages: util-deprecate: 1.0.2 dev: false + /require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + dev: false + /reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -1577,6 +1736,23 @@ packages: engines: {node: '>=0.10.0'} dev: true + /specli@0.0.39(ai@6.0.49)(zod@4.2.1): + resolution: {integrity: sha512-1xS55AI1v0Rp8mQFvTJj3KeJj2Zpbl77X2VyCpWi2/RQUDpLAF6Op96Er8gpkVQ6EcPVxyMAySDQFdiyCLUlJA==} + hasBin: true + peerDependencies: + ai: ^6.0.0 + zod: ^4.0.0 + dependencies: + '@apidevtools/swagger-parser': 12.1.0(openapi-types@12.1.3) + ai: 6.0.49(zod@4.2.1) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + commander: 14.0.2 + openapi-types: 12.1.3 + yaml: 2.8.2 + zod: 4.2.1 + dev: false + /sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} dev: false @@ -1861,4 +2037,3 @@ packages: /zod@4.2.1: resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} - dev: true diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 48c7c9ec..ee159f13 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -97,7 +97,7 @@ export type CommandName = | "whoami"; /** Network command names (only available when network is configured) */ -export type NetworkCommandName = "curl"; +export type NetworkCommandName = "curl" | "specli"; /** All command names including network commands */ export type AllCommandName = CommandName | NetworkCommandName; @@ -491,6 +491,14 @@ const networkCommandLoaders: LazyCommandDef[] = [ }, ]; +// specli doesn't work in browsers (uses Node.js-specific dependencies) +if (typeof __BROWSER__ === "undefined" || !__BROWSER__) { + networkCommandLoaders.push({ + name: "specli" as NetworkCommandName, + load: async () => (await import("./specli/specli.js")).specliCommand, + }); +} + // Cache for loaded commands const cache = new Map(); diff --git a/src/commands/specli/specli.test.ts b/src/commands/specli/specli.test.ts new file mode 100644 index 00000000..971a86d7 --- /dev/null +++ b/src/commands/specli/specli.test.ts @@ -0,0 +1,351 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { Bash } from "../../Bash.js"; + +// Store original fetch +const originalFetch = global.fetch; + +// Minimal OpenAPI spec for testing +const minimalOpenApiSpec = { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0", + }, + servers: [{ url: "https://api.example.com" }], + paths: { + "/users": { + get: { + operationId: "listUsers", + tags: ["users"], + summary: "List all users", + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: { + type: "array", + items: { type: "object" }, + }, + }, + }, + }, + }, + }, + }, + "/users/{id}": { + get: { + operationId: "getUser", + tags: ["users"], + summary: "Get user by ID", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { description: "Success" }, + }, + }, + }, + }, +}; + +// OpenAPI spec with security for auth tests +const securedOpenApiSpec = { + openapi: "3.0.0", + info: { + title: "Test API", + version: "1.0.0", + }, + servers: [{ url: "https://api.example.com" }], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + }, + }, + }, + security: [{ bearerAuth: [] }], + paths: { + "/users": { + get: { + operationId: "listUsers", + tags: ["users"], + summary: "List all users", + responses: { + "200": { description: "Success" }, + }, + }, + }, + }, +}; + +// Mock fetch implementation +function createMockFetch(): ReturnType> { + return vi.fn( + async (url: string | URL | Request, _init?: RequestInit) => { + const urlString = typeof url === "string" ? url : url.toString(); + + // Mock API responses + if (urlString === "https://api.example.com/users") { + return new Response(JSON.stringify([{ id: "1", name: "Alice" }]), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (urlString.startsWith("https://api.example.com/users/")) { + const id = urlString.split("/").pop(); + return new Response(JSON.stringify({ id, name: "User" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + return new Response("Not Found", { status: 404 }); + }, + ); +} + +describe("specli", () => { + let mockFetch: ReturnType; + + beforeAll(() => { + mockFetch = createMockFetch(); + global.fetch = mockFetch as typeof fetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe("help", () => { + it("should show help with --help", async () => { + const env = new Bash({ + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + const result = await env.exec("specli --help"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("specli"); + expect(result.stdout).toContain("Turn any OpenAPI spec into a CLI"); + expect(result.stderr).toBe(""); + }); + + it("should error without subcommand", async () => { + const env = new Bash({ + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + const result = await env.exec("specli"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("missing subcommand"); + }); + + it("should error on unknown subcommand", async () => { + const env = new Bash({ + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + const result = await env.exec("specli unknown"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("invalid option"); + }); + + it("should error on compile (not supported)", async () => { + const env = new Bash({ + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + const result = await env.exec("specli compile ./spec.json"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("not supported"); + expect(result.stderr).toContain("requires Bun"); + }); + }); + + describe("exec without network", () => { + it("should not be available when network not configured", async () => { + const env = new Bash(); + const result = await env.exec("specli exec ./spec.json __schema"); + // specli is only registered when network is configured + expect(result.exitCode).toBe(127); + expect(result.stderr).toContain("command not found"); + }); + }); + + describe("exec with file spec", () => { + it("should list schema with __schema", async () => { + const env = new Bash({ + files: { + "/spec.json": JSON.stringify(minimalOpenApiSpec), + }, + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + + const result = await env.exec("specli exec /spec.json __schema"); + expect(result.exitCode).toBe(0); + // Schema output shows title, resources summary, and hints + expect(result.stdout).toContain("Test API"); + expect(result.stdout).toContain("users"); + expect(result.stdout).toContain("Resources:"); + expect(result.stderr).toBe(""); + }); + + it("should list schema with __schema --json", async () => { + const env = new Bash({ + files: { + "/spec.json": JSON.stringify(minimalOpenApiSpec), + }, + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + + const result = await env.exec("specli exec /spec.json __schema --json"); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + // JSON schema output is an object with title, version, resources, etc. + expect(parsed.ok).toBe(true); + expect(parsed.data).toBeDefined(); + expect(parsed.data.title).toBe("Test API"); + expect(result.stderr).toBe(""); + }); + + it("should show resource actions when only resource given", async () => { + const env = new Bash({ + files: { + "/spec.json": JSON.stringify(minimalOpenApiSpec), + }, + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + + const result = await env.exec("specli exec /spec.json users"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("users actions"); + expect(result.stdout).toContain("list"); + expect(result.stdout).toContain("get"); + expect(result.stderr).toBe(""); + }); + + it("should error on unknown resource", async () => { + const env = new Bash({ + files: { + "/spec.json": JSON.stringify(minimalOpenApiSpec), + }, + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + + const result = await env.exec("specli exec /spec.json unknown"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown resource"); + }); + + it("should execute API call", async () => { + mockFetch.mockClear(); + const env = new Bash({ + files: { + "/spec.json": JSON.stringify(minimalOpenApiSpec), + }, + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + + const result = await env.exec("specli exec /spec.json users list"); + expect(result.exitCode).toBe(0); + expect(mockFetch).toHaveBeenCalled(); + // Result should contain the mock response + expect(result.stdout).toContain("Alice"); + expect(result.stderr).toBe(""); + }); + + it("should execute API call with path parameter", async () => { + mockFetch.mockClear(); + const env = new Bash({ + files: { + "/spec.json": JSON.stringify(minimalOpenApiSpec), + }, + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + + const result = await env.exec("specli exec /spec.json users get abc123"); + expect(result.exitCode).toBe(0); + expect(mockFetch).toHaveBeenCalled(); + // Check that the URL included the path parameter + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("abc123"); + expect(result.stderr).toBe(""); + }); + + it("should execute API call with --json output", async () => { + mockFetch.mockClear(); + const env = new Bash({ + files: { + "/spec.json": JSON.stringify(minimalOpenApiSpec), + }, + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + + const result = await env.exec("specli exec /spec.json users list --json"); + expect(result.exitCode).toBe(0); + // JSON output should be parseable + const parsed = JSON.parse(result.stdout); + expect(parsed).toBeDefined(); + expect(result.stderr).toBe(""); + }); + + it("should error on missing spec file", async () => { + const env = new Bash({ + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + + const result = await env.exec("specli exec /missing.json __schema"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("ENOENT"); + }); + }); + + describe("exec argument parsing", () => { + it("should error when spec is missing", async () => { + const env = new Bash({ + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + + const result = await env.exec("specli exec"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("missing spec"); + }); + + it("should error when resource is missing", async () => { + const env = new Bash({ + files: { + "/spec.json": JSON.stringify(minimalOpenApiSpec), + }, + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + + const result = await env.exec("specli exec /spec.json"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("missing resource"); + }); + }); + + describe("authentication options", () => { + it("should pass bearer token when spec has security scheme", async () => { + mockFetch.mockClear(); + const env = new Bash({ + files: { + "/spec.json": JSON.stringify(securedOpenApiSpec), + }, + network: { allowedUrlPrefixes: ["https://api.example.com"] }, + }); + + const result = await env.exec( + "specli exec /spec.json users list --bearer-token mytoken123", + ); + expect(result.exitCode).toBe(0); + // The token should be passed through to the fetch call via our wrapper + // Our wrapper extracts headers from the Request object + expect(mockFetch).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/commands/specli/specli.ts b/src/commands/specli/specli.ts new file mode 100644 index 00000000..548c83fb --- /dev/null +++ b/src/commands/specli/specli.ts @@ -0,0 +1,503 @@ +/** + * specli - Turn any OpenAPI spec into a CLI + * + * This command wraps the specli npm package to provide OpenAPI CLI functionality. + * Network access must be explicitly configured via BashEnvOptions.network. + */ + +import { getExitCode, renderToString, specli } from "specli"; +import type { SecureFetch } from "../../network/index.js"; +import type { Command, CommandContext, ExecResult } from "../../types.js"; +import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; + +/** + * Create a standard fetch-compatible wrapper around SecureFetch. + * This adapts the SecureFetch interface to the standard fetch signature + * that specli expects. + */ +function createFetchWrapper(secureFetch: SecureFetch): typeof globalThis.fetch { + return async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + // Check if input is a Request object + const isRequest = + typeof input === "object" && + input !== null && + "url" in input && + "method" in input; + + // Extract URL string + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : (input as Request).url; + + // Extract method - prefer init, then Request object, then default to GET + const method = + init?.method ?? (isRequest ? (input as Request).method : "GET"); + + // Extract headers - merge from Request object and init + const headers: Record = {}; + + // First, get headers from Request object if present + if (isRequest) { + const reqHeaders = (input as Request).headers; + if (reqHeaders) { + reqHeaders.forEach((value, key) => { + headers[key] = value; + }); + } + } + + // Then, override/add headers from init + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.forEach((value, key) => { + headers[key] = value; + }); + } else if (Array.isArray(init.headers)) { + for (const [key, value] of init.headers) { + headers[key] = value; + } + } else { + Object.assign(headers, init.headers); + } + } + + // Extract body - prefer init, then Request object + let body: string | undefined; + const bodySource = init?.body; + if (bodySource) { + if (typeof bodySource === "string") { + body = bodySource; + } else if (bodySource instanceof ArrayBuffer) { + body = new TextDecoder().decode(bodySource); + } else if (ArrayBuffer.isView(bodySource)) { + body = new TextDecoder().decode(bodySource); + } else { + // For other body types (ReadableStream, FormData, etc.), convert to string + body = String(bodySource); + } + } + + // Call SecureFetch + const result = await secureFetch(url, { + method, + headers: Object.keys(headers).length > 0 ? headers : undefined, + body, + followRedirects: init?.redirect !== "manual", + }); + + // Convert FetchResult to Response + const responseHeaders = new Headers(); + for (const [key, value] of Object.entries(result.headers)) { + responseHeaders.set(key, value); + } + + return new Response(result.body, { + status: result.status, + statusText: result.statusText, + headers: responseHeaders, + }); + }; +} + +const specliHelp = { + name: "specli", + summary: "Turn any OpenAPI spec into a CLI", + usage: "specli exec [resource] [action] [args...] [options]", + description: `Execute commands dynamically from any OpenAPI spec URL or file path. + +Use '__schema' as the resource to inspect available commands.`, + options: [ + " --server override server/base URL", + " --server-var server variable (repeatable)", + " --auth select auth scheme", + " --bearer-token bearer token for authentication", + " --oauth-token OAuth token (alias for --bearer-token)", + " --username basic auth username", + " --password basic auth password", + " --api-key API key value", + " --profile profile name", + " --json machine-readable output", + " --curl print curl command without executing", + " --dry-run print request details without executing", + " --header extra header (repeatable)", + " --timeout request timeout in milliseconds", + " --help display this help and exit", + ], + examples: [ + "specli exec ./openapi.json __schema", + "specli exec ./openapi.json __schema --json", + "specli exec ./openapi.json users list", + "specli exec ./openapi.json users get abc123", + "specli exec https://api.example.com/openapi.json users list --bearer-token $TOKEN", + ], +}; + +interface ParsedOptions { + spec: string; + resource?: string; + action?: string; + positionalArgs: string[]; + flags: Record; + server?: string; + serverVars: Record; + bearerToken?: string; + apiKey?: string; + username?: string; + password?: string; + authScheme?: string; + jsonOutput: boolean; + curl: boolean; + dryRun: boolean; +} + +function parseExecArgs( + args: string[], +): { ok: true; options: ParsedOptions } | { ok: false; error: ExecResult } { + if (args.length === 0) { + return { + ok: false, + error: { + stdout: "", + stderr: "specli exec: missing spec argument\n", + exitCode: 1, + }, + }; + } + + const spec = args[0]; + const positionalArgs: string[] = []; + const flags: Record = {}; + const serverVars: Record = {}; + + let server: string | undefined; + let bearerToken: string | undefined; + let apiKey: string | undefined; + let username: string | undefined; + let password: string | undefined; + let authScheme: string | undefined; + let jsonOutput = false; + let curl = false; + let dryRun = false; + + let i = 1; // Start after spec + while (i < args.length) { + const arg = args[i]; + + if (arg === "--server" && i + 1 < args.length) { + server = args[++i]; + } else if (arg.startsWith("--server=")) { + server = arg.slice("--server=".length); + } else if (arg === "--server-var" && i + 1 < args.length) { + const kv = args[++i]; + const eqIdx = kv.indexOf("="); + if (eqIdx > 0) { + serverVars[kv.slice(0, eqIdx)] = kv.slice(eqIdx + 1); + } + } else if (arg.startsWith("--server-var=")) { + const kv = arg.slice("--server-var=".length); + const eqIdx = kv.indexOf("="); + if (eqIdx > 0) { + serverVars[kv.slice(0, eqIdx)] = kv.slice(eqIdx + 1); + } + } else if (arg === "--auth" && i + 1 < args.length) { + authScheme = args[++i]; + } else if (arg.startsWith("--auth=")) { + authScheme = arg.slice("--auth=".length); + } else if ( + (arg === "--bearer-token" || arg === "--oauth-token") && + i + 1 < args.length + ) { + bearerToken = args[++i]; + } else if ( + arg.startsWith("--bearer-token=") || + arg.startsWith("--oauth-token=") + ) { + bearerToken = arg.slice(arg.indexOf("=") + 1); + } else if (arg === "--username" && i + 1 < args.length) { + username = args[++i]; + } else if (arg.startsWith("--username=")) { + username = arg.slice("--username=".length); + } else if (arg === "--password" && i + 1 < args.length) { + password = args[++i]; + } else if (arg.startsWith("--password=")) { + password = arg.slice("--password=".length); + } else if (arg === "--api-key" && i + 1 < args.length) { + apiKey = args[++i]; + } else if (arg.startsWith("--api-key=")) { + apiKey = arg.slice("--api-key=".length); + } else if (arg === "--profile" && i + 1 < args.length) { + // Skip profile for now (requires file system config) + i++; + } else if (arg.startsWith("--profile=")) { + // Skip profile for now + } else if (arg === "--json") { + jsonOutput = true; + } else if (arg === "--curl") { + curl = true; + } else if (arg === "--dry-run") { + dryRun = true; + } else if (arg === "--header" && i + 1 < args.length) { + const header = args[++i]; + const colonIdx = header.indexOf(":"); + const eqIdx = header.indexOf("="); + const sepIdx = + colonIdx > 0 + ? eqIdx > 0 + ? Math.min(colonIdx, eqIdx) + : colonIdx + : eqIdx; + if (sepIdx > 0) { + const name = header.slice(0, sepIdx).trim(); + const value = header.slice(sepIdx + 1).trim(); + if (!flags.header) flags.header = {}; + (flags.header as Record)[name] = value; + } + } else if (arg.startsWith("--header=")) { + const header = arg.slice("--header=".length); + const colonIdx = header.indexOf(":"); + const eqIdx = header.indexOf("="); + const sepIdx = + colonIdx > 0 + ? eqIdx > 0 + ? Math.min(colonIdx, eqIdx) + : colonIdx + : eqIdx; + if (sepIdx > 0) { + const name = header.slice(0, sepIdx).trim(); + const value = header.slice(sepIdx + 1).trim(); + if (!flags.header) flags.header = {}; + (flags.header as Record)[name] = value; + } + } else if (arg === "--timeout" && i + 1 < args.length) { + flags.timeout = parseInt(args[++i], 10); + } else if (arg.startsWith("--timeout=")) { + flags.timeout = parseInt(arg.slice("--timeout=".length), 10); + } else if (arg === "--accept" && i + 1 < args.length) { + flags.accept = args[++i]; + } else if (arg.startsWith("--accept=")) { + flags.accept = arg.slice("--accept=".length); + } else if (arg === "--data" && i + 1 < args.length) { + flags.data = args[++i]; + } else if (arg.startsWith("--data=")) { + flags.data = arg.slice("--data=".length); + } else if (arg === "--content-type" && i + 1 < args.length) { + flags.contentType = args[++i]; + } else if (arg.startsWith("--content-type=")) { + flags.contentType = arg.slice("--content-type=".length); + } else if (arg.startsWith("--") && !arg.startsWith("--no-")) { + // Handle dynamic flags from the spec (e.g., --name, --email) + const eqIdx = arg.indexOf("="); + if (eqIdx > 0) { + const name = arg.slice(2, eqIdx); + const value = arg.slice(eqIdx + 1); + flags[name] = value; + } else if (i + 1 < args.length && !args[i + 1].startsWith("-")) { + const name = arg.slice(2); + flags[name] = args[++i]; + } else { + // Boolean flag + const name = arg.slice(2); + flags[name] = true; + } + } else if (arg.startsWith("--no-")) { + const name = arg.slice(5); + flags[name] = false; + } else if (!arg.startsWith("-")) { + positionalArgs.push(arg); + } + + i++; + } + + const resource = positionalArgs[0]; + const action = positionalArgs[1]; + const restArgs = positionalArgs.slice(2); + + return { + ok: true, + options: { + spec, + resource, + action, + positionalArgs: restArgs, + flags, + server, + serverVars, + bearerToken, + apiKey, + username, + password, + authScheme, + jsonOutput, + curl, + dryRun, + }, + }; +} + +export const specliCommand: Command = { + name: "specli", + + async execute(args: string[], ctx: CommandContext): Promise { + if (hasHelpFlag(args)) { + return showHelp(specliHelp); + } + + // First argument should be subcommand + if (args.length === 0) { + return { + stdout: "", + stderr: "specli: missing subcommand (use 'exec' or '--help')\n", + exitCode: 1, + }; + } + + const subcommand = args[0]; + + if (subcommand === "compile") { + return { + stdout: "", + stderr: + "specli compile: not supported (requires Bun runtime)\nUse 'bunx specli compile' directly instead.\n", + exitCode: 1, + }; + } + + if (subcommand !== "exec") { + return unknownOption("specli", subcommand); + } + + // ctx.fetch is required for network access + if (!ctx.fetch) { + return { + stdout: "", + stderr: "specli: network access not available\n", + exitCode: 1, + }; + } + + // Parse exec arguments + const parsed = parseExecArgs(args.slice(1)); + if (!parsed.ok) { + return parsed.error; + } + + const opts = parsed.options; + + // Resolve spec path if it's a local file + let specPath = opts.spec; + if (!specPath.startsWith("http://") && !specPath.startsWith("https://")) { + specPath = ctx.fs.resolvePath(ctx.cwd, specPath); + } + + // Create fetch wrapper for specli + const fetchWrapper = createFetchWrapper(ctx.fetch); + + // Create fs wrapper for specli to read from our virtual filesystem + const fsWrapper = { + readFile: (path: string) => ctx.fs.readFile(path), + }; + + try { + // Create specli client with our secure fetch and fs wrappers + const client = await specli({ + spec: specPath, + server: opts.server, + serverVars: + Object.keys(opts.serverVars).length > 0 ? opts.serverVars : undefined, + bearerToken: opts.bearerToken, + apiKey: opts.apiKey, + basicAuth: + opts.username && opts.password + ? { username: opts.username, password: opts.password } + : undefined, + authScheme: opts.authScheme, + fetch: fetchWrapper, + fs: fsWrapper, + }); + + // Handle __schema special resource + if (opts.resource === "__schema") { + const schemaResult = client.schema(); + const output = renderToString(schemaResult, { + format: opts.jsonOutput ? "json" : "text", + }); + + return { + stdout: output, + stderr: "", + exitCode: 0, + }; + } + + // Need resource and action for exec + if (!opts.resource) { + return { + stdout: "", + stderr: + "specli exec: missing resource (use '__schema' to list available resources)\n", + exitCode: 1, + }; + } + + if (!opts.action) { + // Try to get help for the resource + const resources = client.list(); + const resource = resources.find((r) => r.name === opts.resource); + if (resource) { + let output = `${resource.name} actions:\n`; + for (const action of resource.actions) { + const argStr = + action.args.length > 0 ? ` <${action.args.join("> <")}>` : ""; + const summary = action.summary ? ` - ${action.summary}` : ""; + output += ` ${action.name}${argStr}${summary}\n`; + } + return { stdout: output, stderr: "", exitCode: 0 }; + } + return { + stdout: "", + stderr: `specli exec: unknown resource '${opts.resource}'\n`, + exitCode: 1, + }; + } + + // Execute the action + if (opts.curl || opts.dryRun) { + opts.flags.curl = opts.curl; + opts.flags.dryRun = opts.dryRun; + } + + const result = await client.exec( + opts.resource, + opts.action, + opts.positionalArgs.length > 0 ? opts.positionalArgs : undefined, + Object.keys(opts.flags).length > 0 ? opts.flags : undefined, + ); + + // Render result + const output = renderToString(result, { + format: opts.jsonOutput ? "json" : "text", + }); + const exitCode = getExitCode(result); + + return { + stdout: `${output}\n`, + stderr: "", + exitCode, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + stdout: "", + stderr: `specli: ${message}\n`, + exitCode: 1, + }; + } + }, +}; From acea8b0737411122c5750c5469211fb7b45805f7 Mon Sep 17 00:00:00 2001 From: Andrew Barba Date: Sat, 24 Jan 2026 21:59:51 -0500 Subject: [PATCH 2/2] move --- src/commands/specli/specli.ts | 99 +---------------------------- src/network/fetch.ts | 113 ++++++++++++++++++++++++++++++++++ src/network/index.ts | 1 + 3 files changed, 116 insertions(+), 97 deletions(-) diff --git a/src/commands/specli/specli.ts b/src/commands/specli/specli.ts index 548c83fb..7f595033 100644 --- a/src/commands/specli/specli.ts +++ b/src/commands/specli/specli.ts @@ -6,105 +6,10 @@ */ import { getExitCode, renderToString, specli } from "specli"; -import type { SecureFetch } from "../../network/index.js"; +import { createStandardFetch } from "../../network/index.js"; import type { Command, CommandContext, ExecResult } from "../../types.js"; import { hasHelpFlag, showHelp, unknownOption } from "../help.js"; -/** - * Create a standard fetch-compatible wrapper around SecureFetch. - * This adapts the SecureFetch interface to the standard fetch signature - * that specli expects. - */ -function createFetchWrapper(secureFetch: SecureFetch): typeof globalThis.fetch { - return async ( - input: RequestInfo | URL, - init?: RequestInit, - ): Promise => { - // Check if input is a Request object - const isRequest = - typeof input === "object" && - input !== null && - "url" in input && - "method" in input; - - // Extract URL string - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : (input as Request).url; - - // Extract method - prefer init, then Request object, then default to GET - const method = - init?.method ?? (isRequest ? (input as Request).method : "GET"); - - // Extract headers - merge from Request object and init - const headers: Record = {}; - - // First, get headers from Request object if present - if (isRequest) { - const reqHeaders = (input as Request).headers; - if (reqHeaders) { - reqHeaders.forEach((value, key) => { - headers[key] = value; - }); - } - } - - // Then, override/add headers from init - if (init?.headers) { - if (init.headers instanceof Headers) { - init.headers.forEach((value, key) => { - headers[key] = value; - }); - } else if (Array.isArray(init.headers)) { - for (const [key, value] of init.headers) { - headers[key] = value; - } - } else { - Object.assign(headers, init.headers); - } - } - - // Extract body - prefer init, then Request object - let body: string | undefined; - const bodySource = init?.body; - if (bodySource) { - if (typeof bodySource === "string") { - body = bodySource; - } else if (bodySource instanceof ArrayBuffer) { - body = new TextDecoder().decode(bodySource); - } else if (ArrayBuffer.isView(bodySource)) { - body = new TextDecoder().decode(bodySource); - } else { - // For other body types (ReadableStream, FormData, etc.), convert to string - body = String(bodySource); - } - } - - // Call SecureFetch - const result = await secureFetch(url, { - method, - headers: Object.keys(headers).length > 0 ? headers : undefined, - body, - followRedirects: init?.redirect !== "manual", - }); - - // Convert FetchResult to Response - const responseHeaders = new Headers(); - for (const [key, value] of Object.entries(result.headers)) { - responseHeaders.set(key, value); - } - - return new Response(result.body, { - status: result.status, - statusText: result.statusText, - headers: responseHeaders, - }); - }; -} - const specliHelp = { name: "specli", summary: "Turn any OpenAPI spec into a CLI", @@ -397,7 +302,7 @@ export const specliCommand: Command = { } // Create fetch wrapper for specli - const fetchWrapper = createFetchWrapper(ctx.fetch); + const fetchWrapper = createStandardFetch(ctx.fetch); // Create fs wrapper for specli to read from our virtual filesystem const fsWrapper = { diff --git a/src/network/fetch.ts b/src/network/fetch.ts index 2a140c37..2078b7e9 100644 --- a/src/network/fetch.ts +++ b/src/network/fetch.ts @@ -187,3 +187,116 @@ async function responseToResult( url, }; } + +/** + * Creates a standard fetch-compatible wrapper around SecureFetch. + * + * This adapter converts our SecureFetch interface to the standard fetch signature, + * making it compatible with libraries that accept a custom fetch implementation + * (e.g., specli, openai, anthropic SDKs). + * + * The wrapper handles: + * - Request objects (extracting URL, method, headers, body) + * - String URLs with RequestInit options + * - URL objects + * + * @example + * ```ts + * const secureFetch = createSecureFetch(config); + * const standardFetch = createStandardFetch(secureFetch); + * + * // Use with libraries expecting standard fetch + * const client = await specli({ spec: "...", fetch: standardFetch }); + * ``` + */ +export function createStandardFetch( + secureFetch: SecureFetch, +): typeof globalThis.fetch { + return async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + // Check if input is a Request object + const isRequest = + typeof input === "object" && + input !== null && + "url" in input && + "method" in input; + + // Extract URL string + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : (input as Request).url; + + // Extract method - prefer init, then Request object, then default to GET + const method = + init?.method ?? (isRequest ? (input as Request).method : "GET"); + + // Extract headers - merge from Request object and init + const headers: Record = {}; + + // First, get headers from Request object if present + if (isRequest) { + const reqHeaders = (input as Request).headers; + if (reqHeaders) { + reqHeaders.forEach((value, key) => { + headers[key] = value; + }); + } + } + + // Then, override/add headers from init + if (init?.headers) { + if (init.headers instanceof Headers) { + init.headers.forEach((value, key) => { + headers[key] = value; + }); + } else if (Array.isArray(init.headers)) { + for (const [key, value] of init.headers) { + headers[key] = value; + } + } else { + Object.assign(headers, init.headers); + } + } + + // Extract body from init + let body: string | undefined; + const bodySource = init?.body; + if (bodySource) { + if (typeof bodySource === "string") { + body = bodySource; + } else if (bodySource instanceof ArrayBuffer) { + body = new TextDecoder().decode(bodySource); + } else if (ArrayBuffer.isView(bodySource)) { + body = new TextDecoder().decode(bodySource); + } else { + // For other body types (ReadableStream, FormData, etc.), convert to string + body = String(bodySource); + } + } + + // Call SecureFetch + const result = await secureFetch(url, { + method, + headers: Object.keys(headers).length > 0 ? headers : undefined, + body, + followRedirects: init?.redirect !== "manual", + }); + + // Convert FetchResult to Response + const responseHeaders = new Headers(); + for (const [key, value] of Object.entries(result.headers)) { + responseHeaders.set(key, value); + } + + return new Response(result.body, { + status: result.status, + statusText: result.statusText, + headers: responseHeaders, + }); + }; +} diff --git a/src/network/index.ts b/src/network/index.ts index 77fb9965..b0730f48 100644 --- a/src/network/index.ts +++ b/src/network/index.ts @@ -6,6 +6,7 @@ export { createSecureFetch, + createStandardFetch, type SecureFetch, type SecureFetchOptions, } from "./fetch.js";