dynamic-openapi-cli
Advanced tools
+692
-45
@@ -7,4 +7,369 @@ #!/usr/bin/env node | ||
| // src/auth/oauth2-auth-code.ts | ||
| import { createHash as createHash2 } from "crypto"; | ||
| // src/auth/browser.ts | ||
| import { spawn } from "child_process"; | ||
| async function openBrowser(url) { | ||
| const opener = pickOpener(); | ||
| if (!opener) return false; | ||
| return new Promise((resolve) => { | ||
| try { | ||
| const child = spawn(opener.command, [...opener.args, url], { | ||
| detached: true, | ||
| stdio: "ignore" | ||
| }); | ||
| child.on("error", () => resolve(false)); | ||
| child.unref(); | ||
| setTimeout(() => resolve(true), 50); | ||
| } catch { | ||
| resolve(false); | ||
| } | ||
| }); | ||
| } | ||
| function pickOpener() { | ||
| if (process.env["BROWSER"]) { | ||
| return { command: process.env["BROWSER"], args: [] }; | ||
| } | ||
| switch (process.platform) { | ||
| case "darwin": | ||
| return { command: "open", args: [] }; | ||
| case "win32": | ||
| return { command: "cmd", args: ["/c", "start", '""'] }; | ||
| default: | ||
| return { command: "xdg-open", args: [] }; | ||
| } | ||
| } | ||
| // src/auth/loopback-server.ts | ||
| import { createServer } from "http"; | ||
| function captureCallback(options) { | ||
| const host = options.host ?? "127.0.0.1"; | ||
| const callbackPath = options.path ?? "/callback"; | ||
| const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3; | ||
| const successBody = options.successBody ?? "<html><body><h1>Login complete</h1><p>You can close this tab and return to your terminal.</p></body></html>"; | ||
| const errorBody = options.errorBody ?? "<html><body><h1>Login failed</h1><p>See your terminal for details.</p></body></html>"; | ||
| return new Promise((resolve, reject) => { | ||
| const server = createServer((req, res) => { | ||
| const url = new URL(req.url ?? "/", `http://${host}:${options.port}`); | ||
| if (url.pathname !== callbackPath) { | ||
| res.writeHead(404, { "Content-Type": "text/plain" }); | ||
| res.end("Not Found"); | ||
| return; | ||
| } | ||
| const params = url.searchParams; | ||
| const code = params.get("code") ?? void 0; | ||
| const state = params.get("state") ?? void 0; | ||
| const error = params.get("error") ?? void 0; | ||
| const errorDescription = params.get("error_description") ?? void 0; | ||
| if (error) { | ||
| res.writeHead(400, { | ||
| "Content-Type": "text/html; charset=utf-8", | ||
| Connection: "close" | ||
| }); | ||
| res.end(errorBody); | ||
| } else { | ||
| res.writeHead(200, { | ||
| "Content-Type": "text/html; charset=utf-8", | ||
| Connection: "close" | ||
| }); | ||
| res.end(successBody); | ||
| } | ||
| clearTimeout(timeout); | ||
| const result = {}; | ||
| if (code !== void 0) result.code = code; | ||
| if (state !== void 0) result.state = state; | ||
| if (error !== void 0) result.error = error; | ||
| if (errorDescription !== void 0) result.errorDescription = errorDescription; | ||
| server.closeAllConnections?.(); | ||
| server.close(() => resolve(result)); | ||
| }); | ||
| const timeout = setTimeout(() => { | ||
| server.close(() => reject(new Error(`OAuth2 callback timed out after ${timeoutMs}ms`))); | ||
| }, timeoutMs); | ||
| server.on("error", (err) => { | ||
| clearTimeout(timeout); | ||
| reject(err); | ||
| }); | ||
| server.listen(options.port, host); | ||
| }); | ||
| } | ||
| // src/auth/pkce.ts | ||
| import { createHash, randomBytes } from "crypto"; | ||
| function generatePkce() { | ||
| const verifier = base64UrlEncode(randomBytes(32)); | ||
| const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest()); | ||
| return { verifier, challenge, method: "S256" }; | ||
| } | ||
| function generateState() { | ||
| return base64UrlEncode(randomBytes(16)); | ||
| } | ||
| function base64UrlEncode(bytes) { | ||
| return bytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | ||
| } | ||
| // src/auth/token-cache.ts | ||
| import { mkdir, readFile, rm, writeFile } from "fs/promises"; | ||
| import { homedir } from "os"; | ||
| import path from "path"; | ||
| function tokenCacheDir() { | ||
| const xdg = process.env["XDG_DATA_HOME"]; | ||
| if (xdg && xdg.length > 0) { | ||
| return path.join(xdg, "dynamic-openapi-cli", "tokens"); | ||
| } | ||
| return path.join(homedir(), ".local", "share", "dynamic-openapi-cli", "tokens"); | ||
| } | ||
| function tokenCachePath(key) { | ||
| return path.join(tokenCacheDir(), `${sanitizeKey(key)}.json`); | ||
| } | ||
| async function readTokenCache(key) { | ||
| try { | ||
| const raw = await readFile(tokenCachePath(key), "utf-8"); | ||
| const parsed = JSON.parse(raw); | ||
| if (!parsed || typeof parsed !== "object") return null; | ||
| if (typeof parsed.access_token !== "string") return null; | ||
| if (typeof parsed.expires_at !== "number") return null; | ||
| if (typeof parsed.token_type !== "string") return null; | ||
| if (!Array.isArray(parsed.scopes)) return null; | ||
| return parsed; | ||
| } catch (error) { | ||
| if (error.code === "ENOENT") return null; | ||
| return null; | ||
| } | ||
| } | ||
| async function writeTokenCache(key, token) { | ||
| const dir = tokenCacheDir(); | ||
| await mkdir(dir, { recursive: true, mode: 448 }); | ||
| const file = tokenCachePath(key); | ||
| await writeFile(file, JSON.stringify(token, null, 2), { mode: 384 }); | ||
| } | ||
| async function deleteTokenCache(key) { | ||
| try { | ||
| await rm(tokenCachePath(key), { force: true }); | ||
| } catch { | ||
| } | ||
| } | ||
| function sanitizeKey(key) { | ||
| return key.replace(/[^A-Za-z0-9._-]+/g, "-"); | ||
| } | ||
| // src/auth/oauth2-auth-code.ts | ||
| var DEFAULT_REDIRECT_PORT = 7999; | ||
| var DEFAULT_REFRESH_BUFFER_SECONDS = 30; | ||
| var OAuth2AuthCodeFlow = class { | ||
| config; | ||
| cacheKey; | ||
| cached; | ||
| pendingTokenRequest; | ||
| constructor(config) { | ||
| this.config = config; | ||
| this.cacheKey = deriveCacheKey(config); | ||
| } | ||
| async apply(_url, init) { | ||
| const token = await this.getAccessToken(); | ||
| const headers = new Headers(init.headers); | ||
| headers.set("Authorization", `Bearer ${token}`); | ||
| return { ...init, headers }; | ||
| } | ||
| async refresh(_url, init) { | ||
| this.cached = void 0; | ||
| await deleteTokenCache(this.cacheKey); | ||
| const token = await this.getAccessToken(); | ||
| const headers = new Headers(init.headers); | ||
| headers.set("Authorization", `Bearer ${token}`); | ||
| return { ...init, headers }; | ||
| } | ||
| /** Force a fresh login, bypassing the cache. Used by the `login` subcommand. */ | ||
| async forceLogin() { | ||
| this.cached = void 0; | ||
| await deleteTokenCache(this.cacheKey); | ||
| const token = await this.runLoginFlow(); | ||
| return token; | ||
| } | ||
| /** Wipe the cached token. Used by the `logout` subcommand. */ | ||
| async logout() { | ||
| this.cached = void 0; | ||
| await deleteTokenCache(this.cacheKey); | ||
| } | ||
| async getAccessToken() { | ||
| if (this.pendingTokenRequest) return this.pendingTokenRequest; | ||
| this.pendingTokenRequest = this.resolveToken().finally(() => { | ||
| this.pendingTokenRequest = void 0; | ||
| }); | ||
| return this.pendingTokenRequest; | ||
| } | ||
| async resolveToken() { | ||
| if (!this.cached) this.cached = await readTokenCache(this.cacheKey) ?? void 0; | ||
| const buffer = (this.config.refreshBufferSeconds ?? DEFAULT_REFRESH_BUFFER_SECONDS) * 1e3; | ||
| const now = Date.now(); | ||
| if (this.cached && this.cached.expires_at - buffer > now) { | ||
| return this.cached.access_token; | ||
| } | ||
| if (this.cached?.refresh_token) { | ||
| try { | ||
| const refreshed = await this.runRefreshFlow(this.cached.refresh_token); | ||
| return refreshed.access_token; | ||
| } catch (error) { | ||
| process.stderr.write( | ||
| `oauth2 ${this.config.schemeName}: refresh failed (${describeError(error)}), falling back to interactive login | ||
| ` | ||
| ); | ||
| } | ||
| } | ||
| const token = await this.runLoginFlow(); | ||
| return token.access_token; | ||
| } | ||
| async runLoginFlow() { | ||
| const port = this.config.redirectPort ?? DEFAULT_REDIRECT_PORT; | ||
| const redirectUri = this.config.redirectUri ?? `http://127.0.0.1:${port}/callback`; | ||
| const pkce = generatePkce(); | ||
| const state = generateState(); | ||
| const authUrl = buildAuthorizationUrl(this.config, redirectUri, pkce.challenge, state); | ||
| process.stderr.write(`oauth2 ${this.config.schemeName}: opening browser for login | ||
| `); | ||
| process.stderr.write(` If your browser does not open, visit: | ||
| ${authUrl} | ||
| `); | ||
| const [, callback] = await Promise.all([ | ||
| openBrowser(authUrl), | ||
| captureCallback({ port, host: "127.0.0.1", path: "/callback" }) | ||
| ]); | ||
| if (callback.error) { | ||
| throw new Error( | ||
| `OAuth2 login rejected: ${callback.error}${callback.errorDescription ? ` \u2014 ${callback.errorDescription}` : ""}` | ||
| ); | ||
| } | ||
| if (!callback.code) { | ||
| throw new Error("OAuth2 login did not return an authorization code"); | ||
| } | ||
| if (!callback.state || callback.state !== state) { | ||
| throw new Error("OAuth2 login state mismatch \u2014 possible CSRF, aborting"); | ||
| } | ||
| const tokens = await this.exchangeCode(callback.code, redirectUri, pkce.verifier); | ||
| const cached = toCachedToken(tokens, this.config.scopes); | ||
| this.cached = cached; | ||
| await writeTokenCache(this.cacheKey, cached); | ||
| return cached; | ||
| } | ||
| async runRefreshFlow(refreshToken) { | ||
| const body = new URLSearchParams(); | ||
| body.set("grant_type", "refresh_token"); | ||
| body.set("refresh_token", refreshToken); | ||
| body.set("client_id", this.config.clientId); | ||
| if (this.config.clientSecret) body.set("client_secret", this.config.clientSecret); | ||
| const tokens = await this.postTokenEndpoint(body); | ||
| if (!tokens.refresh_token) tokens.refresh_token = refreshToken; | ||
| const cached = toCachedToken(tokens, this.config.scopes); | ||
| this.cached = cached; | ||
| await writeTokenCache(this.cacheKey, cached); | ||
| return cached; | ||
| } | ||
| async exchangeCode(code, redirectUri, verifier) { | ||
| const body = new URLSearchParams(); | ||
| body.set("grant_type", "authorization_code"); | ||
| body.set("code", code); | ||
| body.set("redirect_uri", redirectUri); | ||
| body.set("client_id", this.config.clientId); | ||
| body.set("code_verifier", verifier); | ||
| if (this.config.clientSecret) body.set("client_secret", this.config.clientSecret); | ||
| return this.postTokenEndpoint(body); | ||
| } | ||
| async postTokenEndpoint(body) { | ||
| const response = await fetch(this.config.tokenUrl, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/x-www-form-urlencoded", | ||
| Accept: "application/json" | ||
| }, | ||
| body | ||
| }); | ||
| if (!response.ok) { | ||
| const text = await response.text().catch(() => ""); | ||
| throw new Error(`OAuth2 token endpoint returned ${response.status}${text ? `: ${text}` : ""}`); | ||
| } | ||
| const json = await response.json(); | ||
| if (!json || typeof json.access_token !== "string") { | ||
| throw new Error("OAuth2 token endpoint did not return an access_token"); | ||
| } | ||
| return json; | ||
| } | ||
| }; | ||
| function buildAuthorizationUrl(config, redirectUri, codeChallenge, state) { | ||
| const url = new URL(config.authorizationUrl); | ||
| url.searchParams.set("response_type", "code"); | ||
| url.searchParams.set("client_id", config.clientId); | ||
| url.searchParams.set("redirect_uri", redirectUri); | ||
| url.searchParams.set("state", state); | ||
| url.searchParams.set("code_challenge", codeChallenge); | ||
| url.searchParams.set("code_challenge_method", "S256"); | ||
| if (config.scopes.length > 0) url.searchParams.set("scope", config.scopes.join(" ")); | ||
| if (config.extraAuthParams) { | ||
| for (const [k, v] of Object.entries(config.extraAuthParams)) { | ||
| url.searchParams.set(k, v); | ||
| } | ||
| } | ||
| return url.toString(); | ||
| } | ||
| function toCachedToken(response, fallbackScopes) { | ||
| const expiresIn = typeof response.expires_in === "number" ? response.expires_in : 3600; | ||
| const scopes = typeof response.scope === "string" && response.scope.length > 0 ? response.scope.split(/\s+/) : fallbackScopes; | ||
| const token = { | ||
| access_token: response.access_token, | ||
| token_type: response.token_type ?? "Bearer", | ||
| expires_at: Date.now() + expiresIn * 1e3, | ||
| scopes | ||
| }; | ||
| if (response.refresh_token) token.refresh_token = response.refresh_token; | ||
| return token; | ||
| } | ||
| function deriveCacheKey(config) { | ||
| const hash = createHash2("sha256").update(config.clientId).update("|").update(config.tokenUrl).update("|").update([...config.scopes].sort().join(" ")).digest("hex").slice(0, 16); | ||
| return `${config.schemeName}-${hash}`; | ||
| } | ||
| function describeError(error) { | ||
| if (error instanceof Error) return error.message; | ||
| return String(error); | ||
| } | ||
| // src/auth/resolve.ts | ||
| function detectOAuth2AuthCode(securitySchemes, env = process.env) { | ||
| for (const [name, scheme] of Object.entries(securitySchemes)) { | ||
| if (!scheme || scheme.type !== "oauth2") continue; | ||
| const flow = scheme.flows?.authorizationCode; | ||
| if (!flow) continue; | ||
| const schemeEnv = envKeyFor(name); | ||
| const clientId = env[`OPENAPI_AUTH_${schemeEnv}_CLIENT_ID`] ?? env["OPENAPI_OAUTH2_CLIENT_ID"]; | ||
| if (!clientId) continue; | ||
| const clientSecret = env[`OPENAPI_AUTH_${schemeEnv}_CLIENT_SECRET`] ?? env["OPENAPI_OAUTH2_CLIENT_SECRET"]; | ||
| const scopeOverride = env[`OPENAPI_AUTH_${schemeEnv}_SCOPES`] ?? env["OPENAPI_OAUTH2_SCOPES"]; | ||
| const portEnv = env[`OPENAPI_AUTH_${schemeEnv}_PORT`] ?? env["OPENAPI_OAUTH2_PORT"]; | ||
| const redirectUri = env[`OPENAPI_AUTH_${schemeEnv}_REDIRECT_URI`] ?? env["OPENAPI_OAUTH2_REDIRECT_URI"]; | ||
| const scopes = scopeOverride ? scopeOverride.split(/[\s,]+/).filter(Boolean) : Object.keys(flow.scopes ?? {}); | ||
| const config = { | ||
| schemeName: name, | ||
| clientId, | ||
| authorizationUrl: flow.authorizationUrl, | ||
| tokenUrl: flow.tokenUrl, | ||
| scopes | ||
| }; | ||
| if (clientSecret) config.clientSecret = clientSecret; | ||
| if (redirectUri) config.redirectUri = redirectUri; | ||
| if (portEnv) { | ||
| const port = Number.parseInt(portEnv, 10); | ||
| if (!Number.isNaN(port) && port > 0) config.redirectPort = port; | ||
| } | ||
| return { schemeName: name, config }; | ||
| } | ||
| return null; | ||
| } | ||
| function createOAuth2AuthCodeAuth(config) { | ||
| return new OAuth2AuthCodeFlow(config); | ||
| } | ||
| function envKeyFor(schemeName) { | ||
| return schemeName.toUpperCase().replace(/[^A-Z0-9]+/g, "_"); | ||
| } | ||
| // src/cli/app.ts | ||
| import { readFile } from "fs/promises"; | ||
| import { readFile as readFile3 } from "fs/promises"; | ||
| import { createCLI, formatErrors } from "dynamic-openapi-tools/cli"; | ||
@@ -15,2 +380,4 @@ import { resolveAuth } from "dynamic-openapi-tools/auth"; | ||
| // src/http/client.ts | ||
| import { readFile as readFile2 } from "fs/promises"; | ||
| import path2 from "path"; | ||
| import { fetchWithRetry } from "dynamic-openapi-tools/utils"; | ||
@@ -62,3 +429,3 @@ var RequestError = class extends Error { | ||
| } | ||
| async function executeOperation(operation, args, config) { | ||
| async function prepareRequest(operation, args, config) { | ||
| const validationErrors = validateRequiredParams(operation, args); | ||
@@ -68,10 +435,10 @@ if (validationErrors.length > 0) { | ||
| } | ||
| let path2 = operation.path; | ||
| let urlPath = operation.path; | ||
| for (const param of operation.parameters) { | ||
| if (param.in === "path" && args[param.name] !== void 0) { | ||
| const value = encodeURIComponent(String(args[param.name])); | ||
| path2 = path2.replaceAll(`{${param.name}}`, value); | ||
| urlPath = urlPath.replaceAll(`{${param.name}}`, value); | ||
| } | ||
| } | ||
| const url = new URL(`${config.baseUrl}${path2}`); | ||
| const url = new URL(`${config.baseUrl}${urlPath}`); | ||
| for (const param of operation.parameters) { | ||
@@ -98,5 +465,8 @@ if (param.in === "query" && args[param.name] !== void 0) { | ||
| let body; | ||
| let bodyInfo = { kind: "none" }; | ||
| if (args["body"] !== void 0 && operation.requestBody) { | ||
| const contentType = getRequestContentType(operation.requestBody); | ||
| body = serializeRequestBody(args["body"], contentType); | ||
| const serialized = await serializeRequestBody(args["body"], contentType); | ||
| body = serialized.body; | ||
| bodyInfo = serialized.info; | ||
| if (body instanceof FormData) { | ||
@@ -121,5 +491,21 @@ headers.delete("Content-Type"); | ||
| } | ||
| return { | ||
| url, | ||
| method: operation.method, | ||
| headers: new Headers(init.headers), | ||
| body: init.body, | ||
| bodyInfo, | ||
| operation | ||
| }; | ||
| } | ||
| async function executeOperation(operation, args, config) { | ||
| const prepared = await prepareRequest(operation, args, config); | ||
| const init = { | ||
| method: prepared.method, | ||
| headers: prepared.headers, | ||
| body: prepared.body | ||
| }; | ||
| let response; | ||
| try { | ||
| response = await fetchWithRetry(url.toString(), init, config.fetchOptions); | ||
| response = await fetchWithRetry(prepared.url.toString(), init, config.fetchOptions); | ||
| } catch (error) { | ||
@@ -131,4 +517,4 @@ const msg = error instanceof Error ? error.message : String(error); | ||
| try { | ||
| init = await config.auth.refresh(url, init); | ||
| response = await fetchWithRetry(url.toString(), init, config.fetchOptions); | ||
| const refreshed = await config.auth.refresh(prepared.url, init); | ||
| response = await fetchWithRetry(prepared.url.toString(), refreshed, config.fetchOptions); | ||
| } catch (error) { | ||
@@ -139,3 +525,3 @@ const msg = error instanceof Error ? error.message : String(error); | ||
| } | ||
| return { response, url: url.toString(), method: operation.method }; | ||
| return { response, url: prepared.url.toString(), method: prepared.method }; | ||
| } | ||
@@ -180,6 +566,9 @@ function validateRequiredParams(operation, args) { | ||
| } | ||
| function serializeRequestBody(body, contentType) { | ||
| async function serializeRequestBody(body, contentType) { | ||
| if (isJsonContentType(contentType)) { | ||
| try { | ||
| return JSON.stringify(body); | ||
| return { | ||
| body: JSON.stringify(body), | ||
| info: { kind: "json", value: body, contentType } | ||
| }; | ||
| } catch { | ||
@@ -191,19 +580,26 @@ throw new Error("request body could not be serialized to JSON"); | ||
| if (mimeType === "application/x-www-form-urlencoded") { | ||
| return serializeUrlEncodedBody(body); | ||
| return serializeUrlEncodedBody(body, contentType); | ||
| } | ||
| if (mimeType === "multipart/form-data") { | ||
| return serializeMultipartBody(body); | ||
| return serializeMultipartBody(body, contentType); | ||
| } | ||
| if (isBinaryContentType(contentType)) { | ||
| return serializeBinaryBody(body); | ||
| return serializeBinaryBody(body, contentType); | ||
| } | ||
| if (typeof body === "string") { | ||
| return body; | ||
| return { | ||
| body, | ||
| info: { kind: "text", value: body, contentType } | ||
| }; | ||
| } | ||
| throw new Error(`request body for content type "${contentType}" must be a string, binary input, or structured form data`); | ||
| } | ||
| function serializeUrlEncodedBody(body) { | ||
| if (typeof body === "string" || body instanceof URLSearchParams) { | ||
| return body; | ||
| function serializeUrlEncodedBody(body, contentType) { | ||
| if (typeof body === "string") { | ||
| const pairs = Array.from(new URLSearchParams(body).entries()); | ||
| return { body, info: { kind: "urlencoded", pairs, contentType } }; | ||
| } | ||
| if (body instanceof URLSearchParams) { | ||
| return { body, info: { kind: "urlencoded", pairs: Array.from(body.entries()), contentType } }; | ||
| } | ||
| if (!isRecord(body)) { | ||
@@ -216,3 +612,6 @@ throw new Error("application/x-www-form-urlencoded body must be an object, string, or URLSearchParams"); | ||
| } | ||
| return params; | ||
| return { | ||
| body: params, | ||
| info: { kind: "urlencoded", pairs: Array.from(params.entries()), contentType } | ||
| }; | ||
| } | ||
@@ -241,5 +640,16 @@ function appendUrlEncodedValue(params, key, value) { | ||
| } | ||
| function serializeMultipartBody(body) { | ||
| async function serializeMultipartBody(body, contentType) { | ||
| if (body instanceof FormData) { | ||
| return body; | ||
| const fields2 = []; | ||
| for (const [name, value] of body.entries()) { | ||
| if (typeof value === "string") { | ||
| fields2.push({ name, kind: "value", value }); | ||
| } else { | ||
| const size = typeof value.size === "number" ? value.size : 0; | ||
| const type = value.type || "application/octet-stream"; | ||
| const filename = value.name ?? "upload.bin"; | ||
| fields2.push({ name, kind: "file", filename, contentType: type, bytes: size }); | ||
| } | ||
| } | ||
| return { body, info: { kind: "multipart", fields: fields2, contentType } }; | ||
| } | ||
@@ -250,19 +660,46 @@ if (!isRecord(body)) { | ||
| const form = new FormData(); | ||
| const fields = []; | ||
| for (const [key, value] of Object.entries(body)) { | ||
| appendMultipartValue(form, key, value); | ||
| await appendMultipartValue(form, fields, key, value); | ||
| } | ||
| return form; | ||
| return { body: form, info: { kind: "multipart", fields, contentType } }; | ||
| } | ||
| function appendMultipartValue(form, key, value) { | ||
| async function appendMultipartValue(form, fields, key, value) { | ||
| if (value === void 0) return; | ||
| if (Array.isArray(value)) { | ||
| for (const item of value) { | ||
| appendMultipartValue(form, key, item); | ||
| await appendMultipartValue(form, fields, key, item); | ||
| } | ||
| return; | ||
| } | ||
| if (typeof value === "string") { | ||
| const fileRef = parseFileReference(value); | ||
| if (fileRef) { | ||
| const fileContents = await readFile2(fileRef.path); | ||
| const bytes = Buffer.from(fileContents); | ||
| const filename = path2.basename(fileRef.path); | ||
| const blob = new Blob([bytes], { type: "application/octet-stream" }); | ||
| form.append(key, blob, filename); | ||
| fields.push({ | ||
| name: key, | ||
| kind: "file", | ||
| path: fileRef.path, | ||
| filename, | ||
| contentType: "application/octet-stream", | ||
| bytes: bytes.byteLength | ||
| }); | ||
| return; | ||
| } | ||
| const literal = unescapeFileReference(value); | ||
| form.append(key, literal); | ||
| fields.push({ name: key, kind: "value", value: literal }); | ||
| return; | ||
| } | ||
| if (isBinaryBodyInput(value)) { | ||
| const bytes = Buffer.from(value.dataBase64, "base64"); | ||
| const blob = new Blob([bytes], { type: value.contentType ?? "application/octet-stream" }); | ||
| form.append(key, blob, value.filename ?? "upload.bin"); | ||
| const filename = value.filename ?? "upload.bin"; | ||
| const type = value.contentType ?? "application/octet-stream"; | ||
| const blob = new Blob([bytes], { type }); | ||
| form.append(key, blob, filename); | ||
| fields.push({ name: key, kind: "file", filename, contentType: type, bytes: bytes.byteLength }); | ||
| return; | ||
@@ -272,2 +709,5 @@ } | ||
| form.append(key, value); | ||
| const filename = value.name ?? "upload.bin"; | ||
| const type = value.type || "application/octet-stream"; | ||
| fields.push({ name: key, kind: "file", filename, contentType: type, bytes: value.size }); | ||
| return; | ||
@@ -277,7 +717,16 @@ } | ||
| const bytes = value instanceof ArrayBuffer ? Uint8Array.from(new Uint8Array(value)) : Uint8Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); | ||
| form.append(key, new Blob([bytes], { type: "application/octet-stream" }), "upload.bin"); | ||
| const blob = new Blob([bytes], { type: "application/octet-stream" }); | ||
| form.append(key, blob, "upload.bin"); | ||
| fields.push({ | ||
| name: key, | ||
| kind: "file", | ||
| filename: "upload.bin", | ||
| contentType: "application/octet-stream", | ||
| bytes: bytes.byteLength | ||
| }); | ||
| return; | ||
| } | ||
| if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { | ||
| if (typeof value === "number" || typeof value === "boolean") { | ||
| form.append(key, String(value)); | ||
| fields.push({ name: key, kind: "value", value: String(value) }); | ||
| return; | ||
@@ -287,21 +736,65 @@ } | ||
| form.append(key, ""); | ||
| fields.push({ name: key, kind: "value", value: "" }); | ||
| return; | ||
| } | ||
| form.append(key, JSON.stringify(value)); | ||
| const serialized = JSON.stringify(value); | ||
| form.append(key, serialized); | ||
| fields.push({ name: key, kind: "value", value: serialized }); | ||
| } | ||
| function serializeBinaryBody(body) { | ||
| if (typeof body === "string" || body instanceof Blob) { | ||
| return body; | ||
| async function serializeBinaryBody(body, contentType) { | ||
| if (typeof body === "string") { | ||
| const fileRef = parseFileReference(body); | ||
| if (fileRef) { | ||
| const bytes = await readFile2(fileRef.path); | ||
| return { | ||
| body: bytes, | ||
| info: { | ||
| kind: "binary", | ||
| contentType, | ||
| filePath: fileRef.path, | ||
| filename: path2.basename(fileRef.path), | ||
| bytes: bytes.byteLength | ||
| } | ||
| }; | ||
| } | ||
| const literal = unescapeFileReference(body); | ||
| return { | ||
| body: literal, | ||
| info: { kind: "binary", contentType, bytes: Buffer.byteLength(literal, "utf-8") } | ||
| }; | ||
| } | ||
| if (body instanceof Blob) { | ||
| return { body, info: { kind: "binary", contentType, bytes: body.size } }; | ||
| } | ||
| if (body instanceof ArrayBuffer) { | ||
| return new Uint8Array(body); | ||
| const bytes = new Uint8Array(body); | ||
| return { body: bytes, info: { kind: "binary", contentType, bytes: bytes.byteLength } }; | ||
| } | ||
| if (ArrayBuffer.isView(body)) { | ||
| return new Uint8Array(body.buffer, body.byteOffset, body.byteLength); | ||
| const bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength); | ||
| return { body: bytes, info: { kind: "binary", contentType, bytes: bytes.byteLength } }; | ||
| } | ||
| if (isBinaryBodyInput(body)) { | ||
| return Buffer.from(body.dataBase64, "base64"); | ||
| const bytes = Buffer.from(body.dataBase64, "base64"); | ||
| return { | ||
| body: bytes, | ||
| info: { | ||
| kind: "binary", | ||
| contentType, | ||
| filename: body.filename, | ||
| bytes: bytes.byteLength | ||
| } | ||
| }; | ||
| } | ||
| throw new Error("binary request body must be a string, Blob, ArrayBuffer, typed array, or { dataBase64, filename?, contentType? }"); | ||
| } | ||
| function parseFileReference(value) { | ||
| if (!value.startsWith("@")) return null; | ||
| if (value.startsWith("@@")) return null; | ||
| return { path: value.slice(1) }; | ||
| } | ||
| function unescapeFileReference(value) { | ||
| if (value.startsWith("@@")) return value.slice(1); | ||
| return value; | ||
| } | ||
| function isRecord(value) { | ||
@@ -498,4 +991,53 @@ return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| // src/cli/curl.ts | ||
| function renderCurl(prepared) { | ||
| const parts = []; | ||
| parts.push(`curl -X ${prepared.method} ${shellQuote(prepared.url.toString())}`); | ||
| const headerEntries = []; | ||
| prepared.headers.forEach((value, key) => { | ||
| headerEntries.push([key, value]); | ||
| }); | ||
| for (const [key, value] of headerEntries) { | ||
| parts.push(` -H ${shellQuote(`${key}: ${value}`)}`); | ||
| } | ||
| const bodyLines = renderBody(prepared); | ||
| for (const line of bodyLines) { | ||
| parts.push(` ${line}`); | ||
| } | ||
| return parts.join(" \\\n"); | ||
| } | ||
| function renderBody(prepared) { | ||
| const info = prepared.bodyInfo; | ||
| switch (info.kind) { | ||
| case "none": | ||
| return []; | ||
| case "json": | ||
| return [`--data ${shellQuote(JSON.stringify(info.value))}`]; | ||
| case "urlencoded": | ||
| return info.pairs.map(([k, v]) => `--data-urlencode ${shellQuote(`${k}=${v}`)}`); | ||
| case "multipart": | ||
| return info.fields.map((field) => { | ||
| if (field.kind === "value") { | ||
| return `-F ${shellQuote(`${field.name}=${field.value}`)}`; | ||
| } | ||
| if (field.path) { | ||
| return `-F ${shellQuote(`${field.name}=@${field.path}`)}`; | ||
| } | ||
| return `-F ${shellQuote(`${field.name}=@${field.filename}`)} # ${field.bytes} bytes, ${field.contentType}`; | ||
| }); | ||
| case "binary": | ||
| if (info.filePath) { | ||
| return [`--data-binary ${shellQuote(`@${info.filePath}`)}`]; | ||
| } | ||
| return [`--data-binary @- # ${info.bytes} bytes`]; | ||
| case "text": | ||
| return [`--data ${shellQuote(info.value)}`]; | ||
| } | ||
| } | ||
| function shellQuote(value) { | ||
| return `'${value.replace(/'/g, `'\\''`)}'`; | ||
| } | ||
| // src/cli/output.ts | ||
| import { writeFile } from "fs/promises"; | ||
| import { writeFile as writeFile2 } from "fs/promises"; | ||
| var INLINE_BINARY_LIMIT = 256 * 1024; | ||
@@ -516,3 +1058,3 @@ async function renderResponse(response, options = {}) { | ||
| const bytes2 = new Uint8Array(await response.arrayBuffer()); | ||
| await writeFile(options.outputFile, bytes2); | ||
| await writeFile2(options.outputFile, bytes2); | ||
| process.stderr.write(`wrote ${bytes2.byteLength} bytes to ${options.outputFile} | ||
@@ -594,2 +1136,7 @@ `); | ||
| default: false | ||
| }, | ||
| "dry-run": { | ||
| type: "boolean", | ||
| description: "Print the equivalent curl command instead of firing the request", | ||
| default: false | ||
| } | ||
@@ -603,3 +1150,3 @@ }; | ||
| const baseUrl = resolveBaseUrl(spec, options.baseUrl, options.serverIndex); | ||
| const auth = resolveAuth(options.authConfig, spec.securitySchemes); | ||
| const auth = resolveAuthWithOAuth2(spec, options.authConfig); | ||
| const httpConfig = { | ||
@@ -619,3 +1166,10 @@ baseUrl, | ||
| }; | ||
| const dryRun = Boolean(args.options["dry-run"]); | ||
| try { | ||
| if (dryRun) { | ||
| const prepared = await prepareRequest(context.operation, merged, httpConfig); | ||
| process.stdout.write(renderCurl(prepared)); | ||
| process.stdout.write("\n"); | ||
| return; | ||
| } | ||
| const { response } = await executeOperation(context.operation, merged, httpConfig); | ||
@@ -697,3 +1251,3 @@ const code = await renderResponse(response, outputOptions); | ||
| for (const [key, value] of Object.entries(args.options)) { | ||
| if (key === "output" || key === "raw" || key === "verbose" || key === "body" || key === "body-file") continue; | ||
| if (key === "output" || key === "raw" || key === "verbose" || key === "dry-run" || key === "body" || key === "body-file") continue; | ||
| if (value !== void 0) merged[key] = value; | ||
@@ -705,4 +1259,7 @@ } | ||
| if (bodyFile) { | ||
| const text = await readFile(bodyFile, "utf-8"); | ||
| const text = await readFile3(bodyFile, "utf-8"); | ||
| merged["body"] = tryParseJson(text); | ||
| } else if (bodyRaw === "-") { | ||
| const text = await readStdin(); | ||
| merged["body"] = tryParseJson(text); | ||
| } else if (bodyRaw !== void 0) { | ||
@@ -714,2 +1271,16 @@ merged["body"] = tryParseJson(bodyRaw); | ||
| } | ||
| function resolveAuthWithOAuth2(spec, authConfig) { | ||
| const resolved = resolveAuth(authConfig, spec.securitySchemes); | ||
| if (resolved) return resolved; | ||
| const detected = detectOAuth2AuthCode(spec.securitySchemes); | ||
| if (detected) return createOAuth2AuthCodeAuth(detected.config); | ||
| return null; | ||
| } | ||
| async function readStdin() { | ||
| const chunks = []; | ||
| for await (const chunk of process.stdin) { | ||
| chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| } | ||
| return Buffer.concat(chunks).toString("utf-8"); | ||
| } | ||
| function pickString(value) { | ||
@@ -898,3 +1469,3 @@ if (value === void 0 || value === null) return void 0; | ||
| // src/cli/bundle.ts | ||
| import path from "path"; | ||
| import path3 from "path"; | ||
| import { createParser } from "dynamic-openapi-tools/cli"; | ||
@@ -975,3 +1546,3 @@ import { buildBundle as toolsBuildBundle } from "dynamic-openapi-tools/bundle"; | ||
| name: String(result.options["name"]), | ||
| out: path.resolve(String(result.options["out"])), | ||
| out: path3.resolve(String(result.options["out"])), | ||
| appVersion: pickString2(result.options["app-version"]), | ||
@@ -999,2 +1570,3 @@ description: pickString2(result.options["description"]) | ||
| var SELF_VERSION = "0.1.0"; | ||
| var SUPPORTED_SHELLS = ["bash", "zsh", "fish"]; | ||
| var BOOTSTRAP_HELP = ` | ||
@@ -1005,2 +1577,3 @@ dynamic-openapi-cli \u2014 Turn any OpenAPI v3 spec into a CLI | ||
| dynamic-openapi-cli -s <url|file> <command> [...args] | ||
| dynamic-openapi-cli -s <url|file> completion <bash|zsh|fish> | ||
| dynamic-openapi-cli bundle -s <url|file> --name <cli-name> --out <path> | ||
@@ -1024,5 +1597,18 @@ | ||
| Subcommands (no spec required): | ||
| Subcommands: | ||
| completion <shell> Print a shell completion script (bash, zsh, fish); requires --source | ||
| login Run the OAuth2 authorization-code flow (spec must declare one) | ||
| logout Remove the cached OAuth2 token | ||
| bundle Package a spec into a standalone bash CLI (run "bundle --help") | ||
| Global options (after the command): | ||
| -o, --output <file> Save response body to file | ||
| --raw Skip pretty-printing | ||
| -V, --verbose Print HTTP status + headers to stderr | ||
| --dry-run Print the equivalent curl command instead of firing the request | ||
| Request body: | ||
| --body <string|-> Inline body; pass "-" to read from stdin | ||
| --body-file <path> Read body from a file | ||
| Environment: | ||
@@ -1037,2 +1623,3 @@ OPENAPI_SOURCE, OPENAPI_BASE_URL, OPENAPI_SERVER_INDEX | ||
| dynamic-openapi-cli -s ./spec.yaml get-pet 42 -o pet.json | ||
| dynamic-openapi-cli -s ./spec.yaml completion bash >> ~/.bashrc | ||
| dynamic-openapi-cli bundle -s ./spec.yaml --name petstore-cli --out ./petstore-cli | ||
@@ -1061,5 +1648,15 @@ `; | ||
| } | ||
| const completionShell = resolveCompletionShell(bootstrap.rest); | ||
| if (completionShell === "invalid") process.exit(2); | ||
| try { | ||
| const doc = await loadSpec(bootstrap.source); | ||
| const spec = await resolveSpec(doc); | ||
| if (bootstrap.rest[0] === "login") { | ||
| await runLogin(spec.securitySchemes); | ||
| return; | ||
| } | ||
| if (bootstrap.rest[0] === "logout") { | ||
| await runLogout(spec.securitySchemes); | ||
| return; | ||
| } | ||
| const cli = buildCli({ | ||
@@ -1073,2 +1670,8 @@ spec, | ||
| }); | ||
| if (completionShell) { | ||
| const script = cli.completion(completionShell); | ||
| process.stdout.write(script); | ||
| if (!script.endsWith("\n")) process.stdout.write("\n"); | ||
| return; | ||
| } | ||
| const exitCode = await runCli(cli, bootstrap.rest); | ||
@@ -1083,2 +1686,46 @@ process.exit(exitCode); | ||
| } | ||
| async function runLogin(securitySchemes) { | ||
| const detected = detectOAuth2AuthCode(securitySchemes); | ||
| if (!detected) { | ||
| process.stderr.write( | ||
| "login: no OAuth2 authorization-code flow is configured. Set OPENAPI_OAUTH2_CLIENT_ID (and OPENAPI_OAUTH2_SCOPES if needed) and ensure the spec declares an authorizationCode flow.\n" | ||
| ); | ||
| process.exit(2); | ||
| } | ||
| const auth = createOAuth2AuthCodeAuth(detected.config); | ||
| const token = await auth.forceLogin(); | ||
| process.stderr.write( | ||
| `login: cached token for scheme "${detected.schemeName}" (expires at ${new Date(token.expires_at).toISOString()}) | ||
| ` | ||
| ); | ||
| } | ||
| async function runLogout(securitySchemes) { | ||
| const detected = detectOAuth2AuthCode(securitySchemes); | ||
| if (!detected) { | ||
| process.stderr.write("logout: no OAuth2 authorization-code flow is configured; nothing to remove.\n"); | ||
| return; | ||
| } | ||
| const auth = createOAuth2AuthCodeAuth(detected.config); | ||
| await auth.logout(); | ||
| process.stderr.write(`logout: removed cached token for scheme "${detected.schemeName}" | ||
| `); | ||
| } | ||
| function resolveCompletionShell(rest) { | ||
| if (rest[0] !== "completion") return null; | ||
| const shell = rest[1]; | ||
| if (!shell) { | ||
| process.stderr.write(`completion: missing shell argument (expected one of: ${SUPPORTED_SHELLS.join(", ")}) | ||
| `); | ||
| return "invalid"; | ||
| } | ||
| if (!isSupportedShell(shell)) { | ||
| process.stderr.write(`completion: unknown shell "${shell}" (expected one of: ${SUPPORTED_SHELLS.join(", ")}) | ||
| `); | ||
| return "invalid"; | ||
| } | ||
| return shell; | ||
| } | ||
| function isSupportedShell(shell) { | ||
| return SUPPORTED_SHELLS.includes(shell); | ||
| } | ||
| function buildFilters(args) { | ||
@@ -1085,0 +1732,0 @@ const filters = {}; |
+68
-2
@@ -1,2 +0,2 @@ | ||
| import { ParsedOperation, ParsedSpec, ParsedServer, OperationFilters } from 'dynamic-openapi-tools/parser'; | ||
| import { OpenAPIV3, ParsedOperation, ParsedSpec, ParsedServer, OperationFilters } from 'dynamic-openapi-tools/parser'; | ||
| export { ExternalDocs, OperationFilter, OperationFilters, ParsedOperation, ParsedParameter, ParsedRequestBody, ParsedResponse, ParsedServer, ParsedServerVariable, ParsedSpec, ParsedTag, filterOperations, loadSpec, resolveSource, resolveSpec } from 'dynamic-openapi-tools/parser'; | ||
@@ -10,2 +10,68 @@ import { ResolvedAuth, AuthConfig } from 'dynamic-openapi-tools/auth'; | ||
| interface CachedToken { | ||
| access_token: string; | ||
| refresh_token?: string; | ||
| token_type: string; | ||
| expires_at: number; | ||
| scopes: string[]; | ||
| } | ||
| interface OAuth2AuthCodeConfig { | ||
| /** Scheme name from the spec (used for user-facing messages and cache key). */ | ||
| schemeName: string; | ||
| clientId: string; | ||
| /** Optional for public clients (when the authorization server accepts PKCE alone). */ | ||
| clientSecret?: string; | ||
| authorizationUrl: string; | ||
| tokenUrl: string; | ||
| scopes: string[]; | ||
| /** Loopback port for the redirect listener. Defaults to 7999. */ | ||
| redirectPort?: number; | ||
| /** Explicit redirect URI override (some providers require pre-registration). */ | ||
| redirectUri?: string; | ||
| /** Extra query params added to the authorization URL (audience, prompt, …). */ | ||
| extraAuthParams?: Record<string, string>; | ||
| /** Seconds to subtract from `expires_in` when deciding if a token is still fresh. */ | ||
| refreshBufferSeconds?: number; | ||
| } | ||
| /** | ||
| * OAuth2 authorization-code flow with PKCE. Caches tokens on disk under | ||
| * $XDG_DATA_HOME/dynamic-openapi-cli/tokens/, refreshes them transparently, | ||
| * and triggers a browser login on first use or when refresh fails. | ||
| */ | ||
| declare class OAuth2AuthCodeFlow implements ResolvedAuth { | ||
| private config; | ||
| private cacheKey; | ||
| private cached?; | ||
| private pendingTokenRequest?; | ||
| constructor(config: OAuth2AuthCodeConfig); | ||
| apply(_url: URL, init: RequestInit): Promise<RequestInit>; | ||
| refresh(_url: URL, init: RequestInit): Promise<RequestInit>; | ||
| /** Force a fresh login, bypassing the cache. Used by the `login` subcommand. */ | ||
| forceLogin(): Promise<CachedToken>; | ||
| /** Wipe the cached token. Used by the `logout` subcommand. */ | ||
| logout(): Promise<void>; | ||
| private getAccessToken; | ||
| private resolveToken; | ||
| private runLoginFlow; | ||
| private runRefreshFlow; | ||
| private exchangeCode; | ||
| private postTokenEndpoint; | ||
| } | ||
| interface DetectedOAuth2AuthCode { | ||
| schemeName: string; | ||
| config: OAuth2AuthCodeConfig; | ||
| } | ||
| /** | ||
| * Scan `securitySchemes` for an OAuth2 scheme whose `authorizationCode` flow | ||
| * is configured and for which a client id is available in the environment. | ||
| * Returns the first match (specs almost never declare more than one). | ||
| */ | ||
| declare function detectOAuth2AuthCode(securitySchemes: Record<string, OpenAPIV3.SecuritySchemeObject>, env?: NodeJS.ProcessEnv): DetectedOAuth2AuthCode | null; | ||
| declare function createOAuth2AuthCodeAuth(config: OAuth2AuthCodeConfig): ResolvedAuth & { | ||
| forceLogin(): Promise<unknown>; | ||
| logout(): Promise<void>; | ||
| }; | ||
| interface HttpClientConfig { | ||
@@ -92,2 +158,2 @@ baseUrl: string; | ||
| export { type BuildCliOptions, type ExecutedRequest, type HttpClientConfig, RequestError, ValidationError, buildBundle, buildCli, buildCommandsFromSpec, executeOperation, resolveBaseUrl, resolveServerUrl, runCli }; | ||
| export { type BuildCliOptions, type ExecutedRequest, type HttpClientConfig, type OAuth2AuthCodeConfig, OAuth2AuthCodeFlow, RequestError, ValidationError, buildBundle, buildCli, buildCommandsFromSpec, createOAuth2AuthCodeAuth, detectOAuth2AuthCode, executeOperation, resolveBaseUrl, resolveServerUrl, runCli }; |
+619
-43
@@ -9,5 +9,374 @@ // src/index.ts | ||
| import { resolveAuth as resolveAuth2 } from "dynamic-openapi-tools/auth"; | ||
| // src/auth/oauth2-auth-code.ts | ||
| import { createHash as createHash2 } from "crypto"; | ||
| // src/auth/browser.ts | ||
| import { spawn } from "child_process"; | ||
| async function openBrowser(url) { | ||
| const opener = pickOpener(); | ||
| if (!opener) return false; | ||
| return new Promise((resolve) => { | ||
| try { | ||
| const child = spawn(opener.command, [...opener.args, url], { | ||
| detached: true, | ||
| stdio: "ignore" | ||
| }); | ||
| child.on("error", () => resolve(false)); | ||
| child.unref(); | ||
| setTimeout(() => resolve(true), 50); | ||
| } catch { | ||
| resolve(false); | ||
| } | ||
| }); | ||
| } | ||
| function pickOpener() { | ||
| if (process.env["BROWSER"]) { | ||
| return { command: process.env["BROWSER"], args: [] }; | ||
| } | ||
| switch (process.platform) { | ||
| case "darwin": | ||
| return { command: "open", args: [] }; | ||
| case "win32": | ||
| return { command: "cmd", args: ["/c", "start", '""'] }; | ||
| default: | ||
| return { command: "xdg-open", args: [] }; | ||
| } | ||
| } | ||
| // src/auth/loopback-server.ts | ||
| import { createServer } from "http"; | ||
| function captureCallback(options) { | ||
| const host = options.host ?? "127.0.0.1"; | ||
| const callbackPath = options.path ?? "/callback"; | ||
| const timeoutMs = options.timeoutMs ?? 5 * 60 * 1e3; | ||
| const successBody = options.successBody ?? "<html><body><h1>Login complete</h1><p>You can close this tab and return to your terminal.</p></body></html>"; | ||
| const errorBody = options.errorBody ?? "<html><body><h1>Login failed</h1><p>See your terminal for details.</p></body></html>"; | ||
| return new Promise((resolve, reject) => { | ||
| const server = createServer((req, res) => { | ||
| const url = new URL(req.url ?? "/", `http://${host}:${options.port}`); | ||
| if (url.pathname !== callbackPath) { | ||
| res.writeHead(404, { "Content-Type": "text/plain" }); | ||
| res.end("Not Found"); | ||
| return; | ||
| } | ||
| const params = url.searchParams; | ||
| const code = params.get("code") ?? void 0; | ||
| const state = params.get("state") ?? void 0; | ||
| const error = params.get("error") ?? void 0; | ||
| const errorDescription = params.get("error_description") ?? void 0; | ||
| if (error) { | ||
| res.writeHead(400, { | ||
| "Content-Type": "text/html; charset=utf-8", | ||
| Connection: "close" | ||
| }); | ||
| res.end(errorBody); | ||
| } else { | ||
| res.writeHead(200, { | ||
| "Content-Type": "text/html; charset=utf-8", | ||
| Connection: "close" | ||
| }); | ||
| res.end(successBody); | ||
| } | ||
| clearTimeout(timeout); | ||
| const result = {}; | ||
| if (code !== void 0) result.code = code; | ||
| if (state !== void 0) result.state = state; | ||
| if (error !== void 0) result.error = error; | ||
| if (errorDescription !== void 0) result.errorDescription = errorDescription; | ||
| server.closeAllConnections?.(); | ||
| server.close(() => resolve(result)); | ||
| }); | ||
| const timeout = setTimeout(() => { | ||
| server.close(() => reject(new Error(`OAuth2 callback timed out after ${timeoutMs}ms`))); | ||
| }, timeoutMs); | ||
| server.on("error", (err) => { | ||
| clearTimeout(timeout); | ||
| reject(err); | ||
| }); | ||
| server.listen(options.port, host); | ||
| }); | ||
| } | ||
| // src/auth/pkce.ts | ||
| import { createHash, randomBytes } from "crypto"; | ||
| function generatePkce() { | ||
| const verifier = base64UrlEncode(randomBytes(32)); | ||
| const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest()); | ||
| return { verifier, challenge, method: "S256" }; | ||
| } | ||
| function generateState() { | ||
| return base64UrlEncode(randomBytes(16)); | ||
| } | ||
| function base64UrlEncode(bytes) { | ||
| return bytes.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | ||
| } | ||
| // src/auth/token-cache.ts | ||
| import { mkdir, readFile, rm, writeFile } from "fs/promises"; | ||
| import { homedir } from "os"; | ||
| import path from "path"; | ||
| function tokenCacheDir() { | ||
| const xdg = process.env["XDG_DATA_HOME"]; | ||
| if (xdg && xdg.length > 0) { | ||
| return path.join(xdg, "dynamic-openapi-cli", "tokens"); | ||
| } | ||
| return path.join(homedir(), ".local", "share", "dynamic-openapi-cli", "tokens"); | ||
| } | ||
| function tokenCachePath(key) { | ||
| return path.join(tokenCacheDir(), `${sanitizeKey(key)}.json`); | ||
| } | ||
| async function readTokenCache(key) { | ||
| try { | ||
| const raw = await readFile(tokenCachePath(key), "utf-8"); | ||
| const parsed = JSON.parse(raw); | ||
| if (!parsed || typeof parsed !== "object") return null; | ||
| if (typeof parsed.access_token !== "string") return null; | ||
| if (typeof parsed.expires_at !== "number") return null; | ||
| if (typeof parsed.token_type !== "string") return null; | ||
| if (!Array.isArray(parsed.scopes)) return null; | ||
| return parsed; | ||
| } catch (error) { | ||
| if (error.code === "ENOENT") return null; | ||
| return null; | ||
| } | ||
| } | ||
| async function writeTokenCache(key, token) { | ||
| const dir = tokenCacheDir(); | ||
| await mkdir(dir, { recursive: true, mode: 448 }); | ||
| const file = tokenCachePath(key); | ||
| await writeFile(file, JSON.stringify(token, null, 2), { mode: 384 }); | ||
| } | ||
| async function deleteTokenCache(key) { | ||
| try { | ||
| await rm(tokenCachePath(key), { force: true }); | ||
| } catch { | ||
| } | ||
| } | ||
| function sanitizeKey(key) { | ||
| return key.replace(/[^A-Za-z0-9._-]+/g, "-"); | ||
| } | ||
| // src/auth/oauth2-auth-code.ts | ||
| var DEFAULT_REDIRECT_PORT = 7999; | ||
| var DEFAULT_REFRESH_BUFFER_SECONDS = 30; | ||
| var OAuth2AuthCodeFlow = class { | ||
| config; | ||
| cacheKey; | ||
| cached; | ||
| pendingTokenRequest; | ||
| constructor(config) { | ||
| this.config = config; | ||
| this.cacheKey = deriveCacheKey(config); | ||
| } | ||
| async apply(_url, init) { | ||
| const token = await this.getAccessToken(); | ||
| const headers = new Headers(init.headers); | ||
| headers.set("Authorization", `Bearer ${token}`); | ||
| return { ...init, headers }; | ||
| } | ||
| async refresh(_url, init) { | ||
| this.cached = void 0; | ||
| await deleteTokenCache(this.cacheKey); | ||
| const token = await this.getAccessToken(); | ||
| const headers = new Headers(init.headers); | ||
| headers.set("Authorization", `Bearer ${token}`); | ||
| return { ...init, headers }; | ||
| } | ||
| /** Force a fresh login, bypassing the cache. Used by the `login` subcommand. */ | ||
| async forceLogin() { | ||
| this.cached = void 0; | ||
| await deleteTokenCache(this.cacheKey); | ||
| const token = await this.runLoginFlow(); | ||
| return token; | ||
| } | ||
| /** Wipe the cached token. Used by the `logout` subcommand. */ | ||
| async logout() { | ||
| this.cached = void 0; | ||
| await deleteTokenCache(this.cacheKey); | ||
| } | ||
| async getAccessToken() { | ||
| if (this.pendingTokenRequest) return this.pendingTokenRequest; | ||
| this.pendingTokenRequest = this.resolveToken().finally(() => { | ||
| this.pendingTokenRequest = void 0; | ||
| }); | ||
| return this.pendingTokenRequest; | ||
| } | ||
| async resolveToken() { | ||
| if (!this.cached) this.cached = await readTokenCache(this.cacheKey) ?? void 0; | ||
| const buffer = (this.config.refreshBufferSeconds ?? DEFAULT_REFRESH_BUFFER_SECONDS) * 1e3; | ||
| const now = Date.now(); | ||
| if (this.cached && this.cached.expires_at - buffer > now) { | ||
| return this.cached.access_token; | ||
| } | ||
| if (this.cached?.refresh_token) { | ||
| try { | ||
| const refreshed = await this.runRefreshFlow(this.cached.refresh_token); | ||
| return refreshed.access_token; | ||
| } catch (error) { | ||
| process.stderr.write( | ||
| `oauth2 ${this.config.schemeName}: refresh failed (${describeError(error)}), falling back to interactive login | ||
| ` | ||
| ); | ||
| } | ||
| } | ||
| const token = await this.runLoginFlow(); | ||
| return token.access_token; | ||
| } | ||
| async runLoginFlow() { | ||
| const port = this.config.redirectPort ?? DEFAULT_REDIRECT_PORT; | ||
| const redirectUri = this.config.redirectUri ?? `http://127.0.0.1:${port}/callback`; | ||
| const pkce = generatePkce(); | ||
| const state = generateState(); | ||
| const authUrl = buildAuthorizationUrl(this.config, redirectUri, pkce.challenge, state); | ||
| process.stderr.write(`oauth2 ${this.config.schemeName}: opening browser for login | ||
| `); | ||
| process.stderr.write(` If your browser does not open, visit: | ||
| ${authUrl} | ||
| `); | ||
| const [, callback] = await Promise.all([ | ||
| openBrowser(authUrl), | ||
| captureCallback({ port, host: "127.0.0.1", path: "/callback" }) | ||
| ]); | ||
| if (callback.error) { | ||
| throw new Error( | ||
| `OAuth2 login rejected: ${callback.error}${callback.errorDescription ? ` \u2014 ${callback.errorDescription}` : ""}` | ||
| ); | ||
| } | ||
| if (!callback.code) { | ||
| throw new Error("OAuth2 login did not return an authorization code"); | ||
| } | ||
| if (!callback.state || callback.state !== state) { | ||
| throw new Error("OAuth2 login state mismatch \u2014 possible CSRF, aborting"); | ||
| } | ||
| const tokens = await this.exchangeCode(callback.code, redirectUri, pkce.verifier); | ||
| const cached = toCachedToken(tokens, this.config.scopes); | ||
| this.cached = cached; | ||
| await writeTokenCache(this.cacheKey, cached); | ||
| return cached; | ||
| } | ||
| async runRefreshFlow(refreshToken) { | ||
| const body = new URLSearchParams(); | ||
| body.set("grant_type", "refresh_token"); | ||
| body.set("refresh_token", refreshToken); | ||
| body.set("client_id", this.config.clientId); | ||
| if (this.config.clientSecret) body.set("client_secret", this.config.clientSecret); | ||
| const tokens = await this.postTokenEndpoint(body); | ||
| if (!tokens.refresh_token) tokens.refresh_token = refreshToken; | ||
| const cached = toCachedToken(tokens, this.config.scopes); | ||
| this.cached = cached; | ||
| await writeTokenCache(this.cacheKey, cached); | ||
| return cached; | ||
| } | ||
| async exchangeCode(code, redirectUri, verifier) { | ||
| const body = new URLSearchParams(); | ||
| body.set("grant_type", "authorization_code"); | ||
| body.set("code", code); | ||
| body.set("redirect_uri", redirectUri); | ||
| body.set("client_id", this.config.clientId); | ||
| body.set("code_verifier", verifier); | ||
| if (this.config.clientSecret) body.set("client_secret", this.config.clientSecret); | ||
| return this.postTokenEndpoint(body); | ||
| } | ||
| async postTokenEndpoint(body) { | ||
| const response = await fetch(this.config.tokenUrl, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/x-www-form-urlencoded", | ||
| Accept: "application/json" | ||
| }, | ||
| body | ||
| }); | ||
| if (!response.ok) { | ||
| const text = await response.text().catch(() => ""); | ||
| throw new Error(`OAuth2 token endpoint returned ${response.status}${text ? `: ${text}` : ""}`); | ||
| } | ||
| const json = await response.json(); | ||
| if (!json || typeof json.access_token !== "string") { | ||
| throw new Error("OAuth2 token endpoint did not return an access_token"); | ||
| } | ||
| return json; | ||
| } | ||
| }; | ||
| function buildAuthorizationUrl(config, redirectUri, codeChallenge, state) { | ||
| const url = new URL(config.authorizationUrl); | ||
| url.searchParams.set("response_type", "code"); | ||
| url.searchParams.set("client_id", config.clientId); | ||
| url.searchParams.set("redirect_uri", redirectUri); | ||
| url.searchParams.set("state", state); | ||
| url.searchParams.set("code_challenge", codeChallenge); | ||
| url.searchParams.set("code_challenge_method", "S256"); | ||
| if (config.scopes.length > 0) url.searchParams.set("scope", config.scopes.join(" ")); | ||
| if (config.extraAuthParams) { | ||
| for (const [k, v] of Object.entries(config.extraAuthParams)) { | ||
| url.searchParams.set(k, v); | ||
| } | ||
| } | ||
| return url.toString(); | ||
| } | ||
| function toCachedToken(response, fallbackScopes) { | ||
| const expiresIn = typeof response.expires_in === "number" ? response.expires_in : 3600; | ||
| const scopes = typeof response.scope === "string" && response.scope.length > 0 ? response.scope.split(/\s+/) : fallbackScopes; | ||
| const token = { | ||
| access_token: response.access_token, | ||
| token_type: response.token_type ?? "Bearer", | ||
| expires_at: Date.now() + expiresIn * 1e3, | ||
| scopes | ||
| }; | ||
| if (response.refresh_token) token.refresh_token = response.refresh_token; | ||
| return token; | ||
| } | ||
| function deriveCacheKey(config) { | ||
| const hash = createHash2("sha256").update(config.clientId).update("|").update(config.tokenUrl).update("|").update([...config.scopes].sort().join(" ")).digest("hex").slice(0, 16); | ||
| return `${config.schemeName}-${hash}`; | ||
| } | ||
| function describeError(error) { | ||
| if (error instanceof Error) return error.message; | ||
| return String(error); | ||
| } | ||
| // src/auth/resolve.ts | ||
| function detectOAuth2AuthCode(securitySchemes, env = process.env) { | ||
| for (const [name, scheme] of Object.entries(securitySchemes)) { | ||
| if (!scheme || scheme.type !== "oauth2") continue; | ||
| const flow = scheme.flows?.authorizationCode; | ||
| if (!flow) continue; | ||
| const schemeEnv = envKeyFor(name); | ||
| const clientId = env[`OPENAPI_AUTH_${schemeEnv}_CLIENT_ID`] ?? env["OPENAPI_OAUTH2_CLIENT_ID"]; | ||
| if (!clientId) continue; | ||
| const clientSecret = env[`OPENAPI_AUTH_${schemeEnv}_CLIENT_SECRET`] ?? env["OPENAPI_OAUTH2_CLIENT_SECRET"]; | ||
| const scopeOverride = env[`OPENAPI_AUTH_${schemeEnv}_SCOPES`] ?? env["OPENAPI_OAUTH2_SCOPES"]; | ||
| const portEnv = env[`OPENAPI_AUTH_${schemeEnv}_PORT`] ?? env["OPENAPI_OAUTH2_PORT"]; | ||
| const redirectUri = env[`OPENAPI_AUTH_${schemeEnv}_REDIRECT_URI`] ?? env["OPENAPI_OAUTH2_REDIRECT_URI"]; | ||
| const scopes = scopeOverride ? scopeOverride.split(/[\s,]+/).filter(Boolean) : Object.keys(flow.scopes ?? {}); | ||
| const config = { | ||
| schemeName: name, | ||
| clientId, | ||
| authorizationUrl: flow.authorizationUrl, | ||
| tokenUrl: flow.tokenUrl, | ||
| scopes | ||
| }; | ||
| if (clientSecret) config.clientSecret = clientSecret; | ||
| if (redirectUri) config.redirectUri = redirectUri; | ||
| if (portEnv) { | ||
| const port = Number.parseInt(portEnv, 10); | ||
| if (!Number.isNaN(port) && port > 0) config.redirectPort = port; | ||
| } | ||
| return { schemeName: name, config }; | ||
| } | ||
| return null; | ||
| } | ||
| function createOAuth2AuthCodeAuth(config) { | ||
| return new OAuth2AuthCodeFlow(config); | ||
| } | ||
| function envKeyFor(schemeName) { | ||
| return schemeName.toUpperCase().replace(/[^A-Z0-9]+/g, "_"); | ||
| } | ||
| // src/index.ts | ||
| import { fetchWithRetry as fetchWithRetry2 } from "dynamic-openapi-tools/utils"; | ||
| // src/http/client.ts | ||
| import { readFile as readFile2 } from "fs/promises"; | ||
| import path2 from "path"; | ||
| import { fetchWithRetry } from "dynamic-openapi-tools/utils"; | ||
@@ -59,3 +428,3 @@ var RequestError = class extends Error { | ||
| } | ||
| async function executeOperation(operation, args, config) { | ||
| async function prepareRequest(operation, args, config) { | ||
| const validationErrors = validateRequiredParams(operation, args); | ||
@@ -65,10 +434,10 @@ if (validationErrors.length > 0) { | ||
| } | ||
| let path2 = operation.path; | ||
| let urlPath = operation.path; | ||
| for (const param of operation.parameters) { | ||
| if (param.in === "path" && args[param.name] !== void 0) { | ||
| const value = encodeURIComponent(String(args[param.name])); | ||
| path2 = path2.replaceAll(`{${param.name}}`, value); | ||
| urlPath = urlPath.replaceAll(`{${param.name}}`, value); | ||
| } | ||
| } | ||
| const url = new URL(`${config.baseUrl}${path2}`); | ||
| const url = new URL(`${config.baseUrl}${urlPath}`); | ||
| for (const param of operation.parameters) { | ||
@@ -95,5 +464,8 @@ if (param.in === "query" && args[param.name] !== void 0) { | ||
| let body; | ||
| let bodyInfo = { kind: "none" }; | ||
| if (args["body"] !== void 0 && operation.requestBody) { | ||
| const contentType = getRequestContentType(operation.requestBody); | ||
| body = serializeRequestBody(args["body"], contentType); | ||
| const serialized = await serializeRequestBody(args["body"], contentType); | ||
| body = serialized.body; | ||
| bodyInfo = serialized.info; | ||
| if (body instanceof FormData) { | ||
@@ -118,5 +490,21 @@ headers.delete("Content-Type"); | ||
| } | ||
| return { | ||
| url, | ||
| method: operation.method, | ||
| headers: new Headers(init.headers), | ||
| body: init.body, | ||
| bodyInfo, | ||
| operation | ||
| }; | ||
| } | ||
| async function executeOperation(operation, args, config) { | ||
| const prepared = await prepareRequest(operation, args, config); | ||
| const init = { | ||
| method: prepared.method, | ||
| headers: prepared.headers, | ||
| body: prepared.body | ||
| }; | ||
| let response; | ||
| try { | ||
| response = await fetchWithRetry(url.toString(), init, config.fetchOptions); | ||
| response = await fetchWithRetry(prepared.url.toString(), init, config.fetchOptions); | ||
| } catch (error) { | ||
@@ -128,4 +516,4 @@ const msg = error instanceof Error ? error.message : String(error); | ||
| try { | ||
| init = await config.auth.refresh(url, init); | ||
| response = await fetchWithRetry(url.toString(), init, config.fetchOptions); | ||
| const refreshed = await config.auth.refresh(prepared.url, init); | ||
| response = await fetchWithRetry(prepared.url.toString(), refreshed, config.fetchOptions); | ||
| } catch (error) { | ||
@@ -136,3 +524,3 @@ const msg = error instanceof Error ? error.message : String(error); | ||
| } | ||
| return { response, url: url.toString(), method: operation.method }; | ||
| return { response, url: prepared.url.toString(), method: prepared.method }; | ||
| } | ||
@@ -177,6 +565,9 @@ function validateRequiredParams(operation, args) { | ||
| } | ||
| function serializeRequestBody(body, contentType) { | ||
| async function serializeRequestBody(body, contentType) { | ||
| if (isJsonContentType(contentType)) { | ||
| try { | ||
| return JSON.stringify(body); | ||
| return { | ||
| body: JSON.stringify(body), | ||
| info: { kind: "json", value: body, contentType } | ||
| }; | ||
| } catch { | ||
@@ -188,19 +579,26 @@ throw new Error("request body could not be serialized to JSON"); | ||
| if (mimeType === "application/x-www-form-urlencoded") { | ||
| return serializeUrlEncodedBody(body); | ||
| return serializeUrlEncodedBody(body, contentType); | ||
| } | ||
| if (mimeType === "multipart/form-data") { | ||
| return serializeMultipartBody(body); | ||
| return serializeMultipartBody(body, contentType); | ||
| } | ||
| if (isBinaryContentType(contentType)) { | ||
| return serializeBinaryBody(body); | ||
| return serializeBinaryBody(body, contentType); | ||
| } | ||
| if (typeof body === "string") { | ||
| return body; | ||
| return { | ||
| body, | ||
| info: { kind: "text", value: body, contentType } | ||
| }; | ||
| } | ||
| throw new Error(`request body for content type "${contentType}" must be a string, binary input, or structured form data`); | ||
| } | ||
| function serializeUrlEncodedBody(body) { | ||
| if (typeof body === "string" || body instanceof URLSearchParams) { | ||
| return body; | ||
| function serializeUrlEncodedBody(body, contentType) { | ||
| if (typeof body === "string") { | ||
| const pairs = Array.from(new URLSearchParams(body).entries()); | ||
| return { body, info: { kind: "urlencoded", pairs, contentType } }; | ||
| } | ||
| if (body instanceof URLSearchParams) { | ||
| return { body, info: { kind: "urlencoded", pairs: Array.from(body.entries()), contentType } }; | ||
| } | ||
| if (!isRecord(body)) { | ||
@@ -213,3 +611,6 @@ throw new Error("application/x-www-form-urlencoded body must be an object, string, or URLSearchParams"); | ||
| } | ||
| return params; | ||
| return { | ||
| body: params, | ||
| info: { kind: "urlencoded", pairs: Array.from(params.entries()), contentType } | ||
| }; | ||
| } | ||
@@ -238,5 +639,16 @@ function appendUrlEncodedValue(params, key, value) { | ||
| } | ||
| function serializeMultipartBody(body) { | ||
| async function serializeMultipartBody(body, contentType) { | ||
| if (body instanceof FormData) { | ||
| return body; | ||
| const fields2 = []; | ||
| for (const [name, value] of body.entries()) { | ||
| if (typeof value === "string") { | ||
| fields2.push({ name, kind: "value", value }); | ||
| } else { | ||
| const size = typeof value.size === "number" ? value.size : 0; | ||
| const type = value.type || "application/octet-stream"; | ||
| const filename = value.name ?? "upload.bin"; | ||
| fields2.push({ name, kind: "file", filename, contentType: type, bytes: size }); | ||
| } | ||
| } | ||
| return { body, info: { kind: "multipart", fields: fields2, contentType } }; | ||
| } | ||
@@ -247,19 +659,46 @@ if (!isRecord(body)) { | ||
| const form = new FormData(); | ||
| const fields = []; | ||
| for (const [key, value] of Object.entries(body)) { | ||
| appendMultipartValue(form, key, value); | ||
| await appendMultipartValue(form, fields, key, value); | ||
| } | ||
| return form; | ||
| return { body: form, info: { kind: "multipart", fields, contentType } }; | ||
| } | ||
| function appendMultipartValue(form, key, value) { | ||
| async function appendMultipartValue(form, fields, key, value) { | ||
| if (value === void 0) return; | ||
| if (Array.isArray(value)) { | ||
| for (const item of value) { | ||
| appendMultipartValue(form, key, item); | ||
| await appendMultipartValue(form, fields, key, item); | ||
| } | ||
| return; | ||
| } | ||
| if (typeof value === "string") { | ||
| const fileRef = parseFileReference(value); | ||
| if (fileRef) { | ||
| const fileContents = await readFile2(fileRef.path); | ||
| const bytes = Buffer.from(fileContents); | ||
| const filename = path2.basename(fileRef.path); | ||
| const blob = new Blob([bytes], { type: "application/octet-stream" }); | ||
| form.append(key, blob, filename); | ||
| fields.push({ | ||
| name: key, | ||
| kind: "file", | ||
| path: fileRef.path, | ||
| filename, | ||
| contentType: "application/octet-stream", | ||
| bytes: bytes.byteLength | ||
| }); | ||
| return; | ||
| } | ||
| const literal = unescapeFileReference(value); | ||
| form.append(key, literal); | ||
| fields.push({ name: key, kind: "value", value: literal }); | ||
| return; | ||
| } | ||
| if (isBinaryBodyInput(value)) { | ||
| const bytes = Buffer.from(value.dataBase64, "base64"); | ||
| const blob = new Blob([bytes], { type: value.contentType ?? "application/octet-stream" }); | ||
| form.append(key, blob, value.filename ?? "upload.bin"); | ||
| const filename = value.filename ?? "upload.bin"; | ||
| const type = value.contentType ?? "application/octet-stream"; | ||
| const blob = new Blob([bytes], { type }); | ||
| form.append(key, blob, filename); | ||
| fields.push({ name: key, kind: "file", filename, contentType: type, bytes: bytes.byteLength }); | ||
| return; | ||
@@ -269,2 +708,5 @@ } | ||
| form.append(key, value); | ||
| const filename = value.name ?? "upload.bin"; | ||
| const type = value.type || "application/octet-stream"; | ||
| fields.push({ name: key, kind: "file", filename, contentType: type, bytes: value.size }); | ||
| return; | ||
@@ -274,7 +716,16 @@ } | ||
| const bytes = value instanceof ArrayBuffer ? Uint8Array.from(new Uint8Array(value)) : Uint8Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)); | ||
| form.append(key, new Blob([bytes], { type: "application/octet-stream" }), "upload.bin"); | ||
| const blob = new Blob([bytes], { type: "application/octet-stream" }); | ||
| form.append(key, blob, "upload.bin"); | ||
| fields.push({ | ||
| name: key, | ||
| kind: "file", | ||
| filename: "upload.bin", | ||
| contentType: "application/octet-stream", | ||
| bytes: bytes.byteLength | ||
| }); | ||
| return; | ||
| } | ||
| if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { | ||
| if (typeof value === "number" || typeof value === "boolean") { | ||
| form.append(key, String(value)); | ||
| fields.push({ name: key, kind: "value", value: String(value) }); | ||
| return; | ||
@@ -284,21 +735,65 @@ } | ||
| form.append(key, ""); | ||
| fields.push({ name: key, kind: "value", value: "" }); | ||
| return; | ||
| } | ||
| form.append(key, JSON.stringify(value)); | ||
| const serialized = JSON.stringify(value); | ||
| form.append(key, serialized); | ||
| fields.push({ name: key, kind: "value", value: serialized }); | ||
| } | ||
| function serializeBinaryBody(body) { | ||
| if (typeof body === "string" || body instanceof Blob) { | ||
| return body; | ||
| async function serializeBinaryBody(body, contentType) { | ||
| if (typeof body === "string") { | ||
| const fileRef = parseFileReference(body); | ||
| if (fileRef) { | ||
| const bytes = await readFile2(fileRef.path); | ||
| return { | ||
| body: bytes, | ||
| info: { | ||
| kind: "binary", | ||
| contentType, | ||
| filePath: fileRef.path, | ||
| filename: path2.basename(fileRef.path), | ||
| bytes: bytes.byteLength | ||
| } | ||
| }; | ||
| } | ||
| const literal = unescapeFileReference(body); | ||
| return { | ||
| body: literal, | ||
| info: { kind: "binary", contentType, bytes: Buffer.byteLength(literal, "utf-8") } | ||
| }; | ||
| } | ||
| if (body instanceof Blob) { | ||
| return { body, info: { kind: "binary", contentType, bytes: body.size } }; | ||
| } | ||
| if (body instanceof ArrayBuffer) { | ||
| return new Uint8Array(body); | ||
| const bytes = new Uint8Array(body); | ||
| return { body: bytes, info: { kind: "binary", contentType, bytes: bytes.byteLength } }; | ||
| } | ||
| if (ArrayBuffer.isView(body)) { | ||
| return new Uint8Array(body.buffer, body.byteOffset, body.byteLength); | ||
| const bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength); | ||
| return { body: bytes, info: { kind: "binary", contentType, bytes: bytes.byteLength } }; | ||
| } | ||
| if (isBinaryBodyInput(body)) { | ||
| return Buffer.from(body.dataBase64, "base64"); | ||
| const bytes = Buffer.from(body.dataBase64, "base64"); | ||
| return { | ||
| body: bytes, | ||
| info: { | ||
| kind: "binary", | ||
| contentType, | ||
| filename: body.filename, | ||
| bytes: bytes.byteLength | ||
| } | ||
| }; | ||
| } | ||
| throw new Error("binary request body must be a string, Blob, ArrayBuffer, typed array, or { dataBase64, filename?, contentType? }"); | ||
| } | ||
| function parseFileReference(value) { | ||
| if (!value.startsWith("@")) return null; | ||
| if (value.startsWith("@@")) return null; | ||
| return { path: value.slice(1) }; | ||
| } | ||
| function unescapeFileReference(value) { | ||
| if (value.startsWith("@@")) return value.slice(1); | ||
| return value; | ||
| } | ||
| function isRecord(value) { | ||
@@ -496,3 +991,3 @@ return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| // src/cli/bundle.ts | ||
| import path from "path"; | ||
| import path3 from "path"; | ||
| import { createParser } from "dynamic-openapi-tools/cli"; | ||
@@ -530,3 +1025,3 @@ import { buildBundle as toolsBuildBundle } from "dynamic-openapi-tools/bundle"; | ||
| // src/cli/app.ts | ||
| import { readFile } from "fs/promises"; | ||
| import { readFile as readFile3 } from "fs/promises"; | ||
| import { createCLI, formatErrors } from "dynamic-openapi-tools/cli"; | ||
@@ -536,4 +1031,53 @@ import { resolveAuth } from "dynamic-openapi-tools/auth"; | ||
| // src/cli/curl.ts | ||
| function renderCurl(prepared) { | ||
| const parts = []; | ||
| parts.push(`curl -X ${prepared.method} ${shellQuote(prepared.url.toString())}`); | ||
| const headerEntries = []; | ||
| prepared.headers.forEach((value, key) => { | ||
| headerEntries.push([key, value]); | ||
| }); | ||
| for (const [key, value] of headerEntries) { | ||
| parts.push(` -H ${shellQuote(`${key}: ${value}`)}`); | ||
| } | ||
| const bodyLines = renderBody(prepared); | ||
| for (const line of bodyLines) { | ||
| parts.push(` ${line}`); | ||
| } | ||
| return parts.join(" \\\n"); | ||
| } | ||
| function renderBody(prepared) { | ||
| const info = prepared.bodyInfo; | ||
| switch (info.kind) { | ||
| case "none": | ||
| return []; | ||
| case "json": | ||
| return [`--data ${shellQuote(JSON.stringify(info.value))}`]; | ||
| case "urlencoded": | ||
| return info.pairs.map(([k, v]) => `--data-urlencode ${shellQuote(`${k}=${v}`)}`); | ||
| case "multipart": | ||
| return info.fields.map((field) => { | ||
| if (field.kind === "value") { | ||
| return `-F ${shellQuote(`${field.name}=${field.value}`)}`; | ||
| } | ||
| if (field.path) { | ||
| return `-F ${shellQuote(`${field.name}=@${field.path}`)}`; | ||
| } | ||
| return `-F ${shellQuote(`${field.name}=@${field.filename}`)} # ${field.bytes} bytes, ${field.contentType}`; | ||
| }); | ||
| case "binary": | ||
| if (info.filePath) { | ||
| return [`--data-binary ${shellQuote(`@${info.filePath}`)}`]; | ||
| } | ||
| return [`--data-binary @- # ${info.bytes} bytes`]; | ||
| case "text": | ||
| return [`--data ${shellQuote(info.value)}`]; | ||
| } | ||
| } | ||
| function shellQuote(value) { | ||
| return `'${value.replace(/'/g, `'\\''`)}'`; | ||
| } | ||
| // src/cli/output.ts | ||
| import { writeFile } from "fs/promises"; | ||
| import { writeFile as writeFile2 } from "fs/promises"; | ||
| var INLINE_BINARY_LIMIT = 256 * 1024; | ||
@@ -554,3 +1098,3 @@ async function renderResponse(response, options = {}) { | ||
| const bytes2 = new Uint8Array(await response.arrayBuffer()); | ||
| await writeFile(options.outputFile, bytes2); | ||
| await writeFile2(options.outputFile, bytes2); | ||
| process.stderr.write(`wrote ${bytes2.byteLength} bytes to ${options.outputFile} | ||
@@ -632,2 +1176,7 @@ `); | ||
| default: false | ||
| }, | ||
| "dry-run": { | ||
| type: "boolean", | ||
| description: "Print the equivalent curl command instead of firing the request", | ||
| default: false | ||
| } | ||
@@ -641,3 +1190,3 @@ }; | ||
| const baseUrl = resolveBaseUrl(spec, options.baseUrl, options.serverIndex); | ||
| const auth = resolveAuth(options.authConfig, spec.securitySchemes); | ||
| const auth = resolveAuthWithOAuth2(spec, options.authConfig); | ||
| const httpConfig = { | ||
@@ -657,3 +1206,10 @@ baseUrl, | ||
| }; | ||
| const dryRun = Boolean(args.options["dry-run"]); | ||
| try { | ||
| if (dryRun) { | ||
| const prepared = await prepareRequest(context.operation, merged, httpConfig); | ||
| process.stdout.write(renderCurl(prepared)); | ||
| process.stdout.write("\n"); | ||
| return; | ||
| } | ||
| const { response } = await executeOperation(context.operation, merged, httpConfig); | ||
@@ -735,3 +1291,3 @@ const code = await renderResponse(response, outputOptions); | ||
| for (const [key, value] of Object.entries(args.options)) { | ||
| if (key === "output" || key === "raw" || key === "verbose" || key === "body" || key === "body-file") continue; | ||
| if (key === "output" || key === "raw" || key === "verbose" || key === "dry-run" || key === "body" || key === "body-file") continue; | ||
| if (value !== void 0) merged[key] = value; | ||
@@ -743,4 +1299,7 @@ } | ||
| if (bodyFile) { | ||
| const text = await readFile(bodyFile, "utf-8"); | ||
| const text = await readFile3(bodyFile, "utf-8"); | ||
| merged["body"] = tryParseJson(text); | ||
| } else if (bodyRaw === "-") { | ||
| const text = await readStdin(); | ||
| merged["body"] = tryParseJson(text); | ||
| } else if (bodyRaw !== void 0) { | ||
@@ -752,2 +1311,16 @@ merged["body"] = tryParseJson(bodyRaw); | ||
| } | ||
| function resolveAuthWithOAuth2(spec, authConfig) { | ||
| const resolved = resolveAuth(authConfig, spec.securitySchemes); | ||
| if (resolved) return resolved; | ||
| const detected = detectOAuth2AuthCode(spec.securitySchemes); | ||
| if (detected) return createOAuth2AuthCodeAuth(detected.config); | ||
| return null; | ||
| } | ||
| async function readStdin() { | ||
| const chunks = []; | ||
| for await (const chunk of process.stdin) { | ||
| chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| } | ||
| return Buffer.concat(chunks).toString("utf-8"); | ||
| } | ||
| function pickString(value) { | ||
@@ -771,2 +1344,3 @@ if (value === void 0 || value === null) return void 0; | ||
| export { | ||
| OAuth2AuthCodeFlow, | ||
| RequestError, | ||
@@ -777,2 +1351,4 @@ ValidationError, | ||
| buildCommandsFromSpec, | ||
| createOAuth2AuthCodeAuth, | ||
| detectOAuth2AuthCode, | ||
| executeOperation, | ||
@@ -779,0 +1355,0 @@ fetchWithRetry2 as fetchWithRetry, |
+1
-1
| { | ||
| "name": "dynamic-openapi-cli", | ||
| "version": "0.1.3", | ||
| "version": "0.1.4", | ||
| "description": "Transform any OpenAPI v3 spec into a fully functional CLI", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+99
-11
@@ -54,3 +54,3 @@ <div align="center"> | ||
| $ ./petstore install | ||
| petstore install: symlinked /home/ff/.local/bin/petstore → /home/ff/work/petstore | ||
| petstore install: symlinked ~/.local/bin/petstore → ./petstore | ||
@@ -250,3 +250,3 @@ # 5. use it anywhere | ||
| Every subcommand gets three global options for free: | ||
| Every subcommand gets four global options for free: | ||
@@ -257,4 +257,53 @@ ``` | ||
| -V, --verbose Print HTTP status + headers to stderr | ||
| --dry-run Print the equivalent curl command instead of firing the request | ||
| ``` | ||
| ### `--dry-run`: preview requests as curl | ||
| Every CLI or bundled shim can print the resolved request as a `curl` command — URL, headers (including the ones resolved by auth), and body — without firing it: | ||
| ```bash | ||
| $ petstore list-pets --dry-run --limit=5 --status=available | ||
| curl -X GET 'https://api.example.com/pets?limit=5&status=available' \ | ||
| -H 'accept: application/json' \ | ||
| -H 'authorization: Bearer sk-…' | ||
| $ petstore create-pet --dry-run --body='{"name":"rex"}' | ||
| curl -X POST 'https://api.example.com/pets' \ | ||
| -H 'accept: application/json' \ | ||
| -H 'content-type: application/json' \ | ||
| --data '{"name":"rex"}' | ||
| ``` | ||
| Useful for copy-pasting into bug reports, sharing a reproducer, or feeding another tool. Works with every body kind: JSON, URL-encoded, multipart, and binary. | ||
| ### Piping bodies: `--body=-`, `--body-file`, and `@path` | ||
| | Source | How | | ||
| |:-------|:----| | ||
| | Inline literal | `--body='{"name":"rex"}'` | | ||
| | From stdin | `echo '{…}' \| my-cli create-pet --body=-` | | ||
| | From a file | `my-cli create-pet --body-file=./new-pet.json` | | ||
| | Multipart file upload | `--body='{"file":"@/path/to/a.pdf","kind":"invoice"}'` | | ||
| | Binary upload | `--body='@/path/to/blob.bin'` (for `application/octet-stream` bodies) | | ||
| `@path` works anywhere a body value can be a file reference — multipart field values, binary request bodies. Prefix with `@@` to escape (a literal string starting with `@`). The CLI reads the file at request time and streams the bytes into the multipart envelope or the request body. | ||
| ### Shell completions | ||
| `cli-args-parser` generates completions for the three major shells. Both the dynamic CLI and bundled shims expose them as `completion <shell>`: | ||
| ```bash | ||
| # Bash | ||
| dynamic-openapi-cli -s ./spec.yaml completion bash >> ~/.bashrc | ||
| # Zsh — drop into a dir on your $fpath, or eval it at shell startup | ||
| petstore completion zsh > "${fpath[1]}/_petstore" | ||
| # Fish | ||
| petstore completion fish > ~/.config/fish/completions/petstore.fish | ||
| ``` | ||
| Completions cover top-level commands and per-command options. Bundled CLIs pass through transparently, so `my-api completion bash` works on any shim. | ||
| Exit codes: | ||
@@ -336,4 +385,4 @@ | ||
| petstore update: fetching https://petstore3.swagger.io/api/v3/openapi.json ... | ||
| bundled "petstore" v1.1.0 → /home/ff/tmp/petstore.update.12345 (7.4 KB, 19 operations) | ||
| petstore update: spec changed (md5 bb864f70 → a12c9e31), /home/ff/bin/petstore 1.0.0 → 1.1.0. | ||
| bundled "petstore" v1.1.0 → /tmp/petstore.update.12345 (7.4 KB, 19 operations) | ||
| petstore update: spec changed (md5 bb864f70 → a12c9e31), ./petstore 1.0.0 → 1.1.0. | ||
@@ -379,6 +428,6 @@ $ petstore --help # petstore 1.1.0 — new operation visible | ||
| ``` | ||
| petstore install: warning — /home/ff/.local/bin is not on your PATH yet. | ||
| petstore install: warning — ~/.local/bin is not on your PATH yet. | ||
| Add this line to your shell rc (~/.bashrc, ~/.zshrc, or equivalent): | ||
| export PATH="/home/ff/.local/bin:$PATH" | ||
| export PATH="$HOME/.local/bin:$PATH" | ||
| ``` | ||
@@ -432,2 +481,3 @@ | ||
| | OAuth2 client credentials | — | `auth.oauth2` (auto-refresh) | | ||
| | OAuth2 authorization code | `OPENAPI_OAUTH2_CLIENT_ID` (+ `OPENAPI_OAUTH2_SCOPES`) | `new OAuth2AuthCodeFlow({…})` | | ||
| | Custom token exchange | — | `auth.tokenExchange` (auto-refresh) | | ||
@@ -458,2 +508,26 @@ | Fully custom | — | `auth.custom` (callback) | | ||
| ### OAuth2 authorization code (browser login) | ||
| When the spec declares an OAuth2 `authorizationCode` flow and `OPENAPI_OAUTH2_CLIENT_ID` is present, the CLI triggers a browser-based login on the first request — PKCE + loopback server on `127.0.0.1:7999` — and caches the token under `$XDG_DATA_HOME/dynamic-openapi-cli/tokens/`. Subsequent calls reuse the cached token and refresh it transparently when it expires. | ||
| ```bash | ||
| export OPENAPI_OAUTH2_CLIENT_ID=cli-public | ||
| export OPENAPI_OAUTH2_SCOPES="read:pets write:pets" | ||
| # optional, defaults shown: | ||
| # export OPENAPI_OAUTH2_PORT=7999 | ||
| # export OPENAPI_OAUTH2_REDIRECT_URI=http://127.0.0.1:7999/callback | ||
| # export OPENAPI_OAUTH2_CLIENT_SECRET=... # confidential clients only | ||
| # Explicit login / logout (spec required): | ||
| dynamic-openapi-cli -s ./spec.yaml login # opens a browser, writes the cached token | ||
| dynamic-openapi-cli -s ./spec.yaml logout # removes the cached token | ||
| # Or just run any operation — login triggers automatically on the first call. | ||
| petstore list-pets | ||
| ``` | ||
| Per-scheme env vars (derived from the scheme name in the spec, same casing rules as other schemes) take precedence: `OPENAPI_AUTH_<SCHEME>_CLIENT_ID`, `OPENAPI_AUTH_<SCHEME>_SCOPES`, `OPENAPI_AUTH_<SCHEME>_PORT`, `OPENAPI_AUTH_<SCHEME>_REDIRECT_URI`, `OPENAPI_AUTH_<SCHEME>_CLIENT_SECRET`. | ||
| Redirect URI must match what the provider has registered — override via `OPENAPI_OAUTH2_REDIRECT_URI` (or per-scheme) when using hosts other than `127.0.0.1:<port>/callback`. The token file has mode `0600`. | ||
| --- | ||
@@ -484,3 +558,12 @@ | ||
| -V, --verbose Print HTTP status + headers to stderr | ||
| --dry-run Print the equivalent curl command instead of firing the request | ||
| Request body (for operations that accept one): | ||
| --body <string|-> Inline body; pass "-" to read from stdin | ||
| --body-file <path> Read body from a file | ||
| Multipart / binary bodies accept "@path" for file uploads. | ||
| Subcommands (spec required): | ||
| completion <shell> Print a shell completion script (bash, zsh, fish) | ||
| Built-in subcommands (no spec required): | ||
@@ -502,2 +585,7 @@ bundle Package a spec into a standalone bash CLI | ||
| | `OPENAPI_AUTH_<SCHEME>_KEY` | Per-scheme API key | | ||
| | `OPENAPI_OAUTH2_CLIENT_ID` | OAuth2 authorization-code client id (activates browser login) | | ||
| | `OPENAPI_OAUTH2_CLIENT_SECRET` | OAuth2 client secret (confidential clients only) | | ||
| | `OPENAPI_OAUTH2_SCOPES` | Space- or comma-separated scope list (overrides spec) | | ||
| | `OPENAPI_OAUTH2_PORT` | Loopback redirect port (default 7999) | | ||
| | `OPENAPI_OAUTH2_REDIRECT_URI` | Full redirect URI override | | ||
@@ -676,7 +764,7 @@ --- | ||
| - [ ] Shell completion scripts (bash / zsh / fish) — `cli-args-parser` already generates them, need to expose on the bundle | ||
| - [ ] `--dry-run` flag that prints the curl equivalent without firing the request | ||
| - [ ] Read request body from stdin (`--body -`) for piping | ||
| - [ ] OAuth2 authorization code flow (browser-based) | ||
| - [ ] First-class multipart uploads from file paths (today: via `{ dataBase64, filename }` JSON) | ||
| - [x] Shell completion scripts (bash / zsh / fish) — `completion <shell>` subcommand | ||
| - [x] `--dry-run` flag that prints the curl equivalent without firing the request | ||
| - [x] Read request body from stdin (`--body=-`) for piping | ||
| - [x] First-class multipart / binary uploads from file paths (`@path`, curl-style) | ||
| - [x] OAuth2 authorization code flow (browser-based, PKCE, disk-cached tokens) | ||
@@ -683,0 +771,0 @@ Got an idea? Open an issue. |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
360416
68.5%3165
66.84%768
12.94%25
92.31%6
Infinity%