dynamic-openapi-cli
Advanced tools
+172
-62
@@ -25,2 +25,113 @@ #!/usr/bin/env node | ||
| import { fetchWithRetry } from "dynamic-openapi-tools/utils"; | ||
| // src/http/curl.ts | ||
| var REDACTED_HEADERS = /* @__PURE__ */ new Set([ | ||
| "authorization", | ||
| "proxy-authorization", | ||
| "cookie", | ||
| "set-cookie", | ||
| "x-api-key", | ||
| "api-key" | ||
| ]); | ||
| 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) { | ||
| const safeValue = REDACTED_HEADERS.has(key.toLowerCase()) ? "***" : value; | ||
| parts.push(` -H ${shellQuote(`${key}: ${safeValue}`)}`); | ||
| } | ||
| 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/http/safety.ts | ||
| var READ_ONLY_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS", "TRACE"]); | ||
| var DESTRUCTIVE_METHODS = /* @__PURE__ */ new Set(["DELETE"]); | ||
| var SAFETY_ENV_VARS = { | ||
| dryRun: "DYNAMIC_OPENAPI_DRY_RUN", | ||
| noDestructive: "DYNAMIC_OPENAPI_NO_DESTRUCTIVE" | ||
| }; | ||
| var SafetyError = class extends Error { | ||
| constructor(message, operationId, sideEffect, reason) { | ||
| super(message); | ||
| this.operationId = operationId; | ||
| this.sideEffect = sideEffect; | ||
| this.reason = reason; | ||
| this.name = "SafetyError"; | ||
| } | ||
| operationId; | ||
| sideEffect; | ||
| reason; | ||
| }; | ||
| function classifySideEffect(operation) { | ||
| const override = readSideEffectExtension(operation); | ||
| if (override) return override; | ||
| const method = operation.method.toUpperCase(); | ||
| if (READ_ONLY_METHODS.has(method)) return "read-only"; | ||
| if (DESTRUCTIVE_METHODS.has(method)) return "destructive"; | ||
| return "write"; | ||
| } | ||
| function readSideEffectExtension(operation) { | ||
| const bag = operation; | ||
| const direct = bag["x-side-effect"]; | ||
| if (typeof direct === "string" && isSideEffect(direct)) return direct; | ||
| if (bag["x-destructive"] === true) return "destructive"; | ||
| const extensions = bag["extensions"]; | ||
| if (extensions && typeof extensions === "object") { | ||
| const ext = extensions; | ||
| const fromBag = ext["x-side-effect"]; | ||
| if (typeof fromBag === "string" && isSideEffect(fromBag)) return fromBag; | ||
| if (ext["x-destructive"] === true) return "destructive"; | ||
| } | ||
| return null; | ||
| } | ||
| function isSideEffect(value) { | ||
| return value === "read-only" || value === "write" || value === "destructive"; | ||
| } | ||
| function isDryRunFloor(env = process.env) { | ||
| return env[SAFETY_ENV_VARS.dryRun] === "1"; | ||
| } | ||
| function isNoDestructiveFloor(env = process.env) { | ||
| return env[SAFETY_ENV_VARS.noDestructive] === "1"; | ||
| } | ||
| // src/http/client.ts | ||
| var RequestError = class extends Error { | ||
@@ -141,2 +252,35 @@ constructor(message, cause) { | ||
| const prepared = await prepareRequest(operation, args, config); | ||
| const sideEffect = classifySideEffect(operation); | ||
| if (sideEffect === "destructive") { | ||
| if (isNoDestructiveFloor()) { | ||
| throw new SafetyError( | ||
| `Destructive operation "${operation.operationId}" blocked by ${SAFETY_ENV_VARS.noDestructive}=1 (floor is absolute).`, | ||
| operation.operationId, | ||
| sideEffect, | ||
| "floor-blocked" | ||
| ); | ||
| } | ||
| if (!config.allowDestructive) { | ||
| throw new SafetyError( | ||
| `Destructive operation "${operation.operationId}" requires consent. Pass --yes (CLI) or allowDestructive: true (programmatic).`, | ||
| operation.operationId, | ||
| sideEffect, | ||
| "no-consent" | ||
| ); | ||
| } | ||
| } | ||
| if (config.dryRun || isDryRunFloor()) { | ||
| const curl = renderCurl(prepared); | ||
| const previewResponse = new Response(curl + "\n", { | ||
| status: 200, | ||
| headers: { "Content-Type": "text/plain; charset=utf-8" } | ||
| }); | ||
| return { | ||
| response: previewResponse, | ||
| url: prepared.url.toString(), | ||
| method: prepared.method, | ||
| dryRun: true, | ||
| sideEffect | ||
| }; | ||
| } | ||
| const init = { | ||
@@ -163,3 +307,3 @@ method: prepared.method, | ||
| } | ||
| return { response, url: prepared.url.toString(), method: prepared.method }; | ||
| return { response, url: prepared.url.toString(), method: prepared.method, sideEffect }; | ||
| } | ||
@@ -621,51 +765,2 @@ function validateRequiredParams(operation, args) { | ||
| // 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 | ||
@@ -770,2 +865,8 @@ import { writeFile } from "fs/promises"; | ||
| default: false | ||
| }, | ||
| yes: { | ||
| short: "y", | ||
| type: "boolean", | ||
| description: "Consent to destructive operations (DELETE, x-side-effect: destructive)", | ||
| default: false | ||
| } | ||
@@ -780,8 +881,2 @@ }; | ||
| const auth = resolveAuthWithOAuth2(spec, options.authConfig, options.name); | ||
| const httpConfig = { | ||
| baseUrl, | ||
| auth, | ||
| defaultHeaders: options.defaultHeaders, | ||
| fetchOptions: options.fetchOptions | ||
| }; | ||
| const { commands, collisions } = buildCommandsFromSpec(spec, { | ||
@@ -795,14 +890,29 @@ handler: async (context, args) => { | ||
| }; | ||
| const dryRun = Boolean(args.options["dry-run"]); | ||
| const httpConfig = { | ||
| baseUrl, | ||
| auth, | ||
| defaultHeaders: options.defaultHeaders, | ||
| fetchOptions: options.fetchOptions, | ||
| dryRun: Boolean(args.options["dry-run"]), | ||
| allowDestructive: Boolean(args.options["yes"]) | ||
| }; | ||
| try { | ||
| const { response, dryRun } = await executeOperation(context.operation, merged, httpConfig); | ||
| if (dryRun) { | ||
| const prepared = await prepareRequest(context.operation, merged, httpConfig); | ||
| process.stdout.write(renderCurl(prepared)); | ||
| process.stdout.write("\n"); | ||
| process.stdout.write(await response.text()); | ||
| return; | ||
| } | ||
| const { response } = await executeOperation(context.operation, merged, httpConfig); | ||
| const code = await renderResponse(response, outputOptions); | ||
| if (code !== 0) process.exitCode = code; | ||
| } catch (error) { | ||
| if (error instanceof SafetyError) { | ||
| process.stderr.write(`safety: ${error.message} | ||
| `); | ||
| if (error.reason === "no-consent") { | ||
| process.stderr.write(` retry with --yes, or set ${SAFETY_ENV_VARS.noDestructive}=0 if a floor is in effect. | ||
| `); | ||
| } | ||
| process.exitCode = 3; | ||
| return; | ||
| } | ||
| if (error instanceof ValidationError) { | ||
@@ -880,3 +990,3 @@ process.stderr.write(`${error.message} | ||
| for (const [key, value] of Object.entries(args.options)) { | ||
| if (key === "output" || key === "raw" || key === "verbose" || key === "dry-run" || key === "body" || key === "body-file") continue; | ||
| if (key === "output" || key === "raw" || key === "verbose" || key === "dry-run" || key === "yes" || key === "body" || key === "body-file") continue; | ||
| if (value !== void 0) merged[key] = value; | ||
@@ -883,0 +993,0 @@ } |
+103
-1
@@ -10,2 +10,42 @@ import { ParsedOperation, ParsedSpec, ParsedServer, OperationFilters } from 'dynamic-openapi-tools/parser'; | ||
| type SideEffect = 'read-only' | 'write' | 'destructive'; | ||
| declare const SAFETY_ENV_VARS: { | ||
| readonly dryRun: "DYNAMIC_OPENAPI_DRY_RUN"; | ||
| readonly noDestructive: "DYNAMIC_OPENAPI_NO_DESTRUCTIVE"; | ||
| }; | ||
| declare class SafetyError extends Error { | ||
| readonly operationId: string; | ||
| readonly sideEffect: SideEffect; | ||
| readonly reason: 'no-consent' | 'floor-blocked'; | ||
| constructor(message: string, operationId: string, sideEffect: SideEffect, reason: 'no-consent' | 'floor-blocked'); | ||
| } | ||
| /** | ||
| * Classify the side-effect of an operation. HTTP method is the baseline. | ||
| * Vendor extensions override: | ||
| * x-side-effect: 'read-only' | 'write' | 'destructive' | ||
| * x-destructive: true (sugar for x-side-effect: 'destructive') | ||
| * | ||
| * The extension can sit on the ParsedOperation itself (parser-exposed) or on a | ||
| * `.extensions` record. Both shapes are supported defensively because the | ||
| * upstream parser does not type-export extensions on every release. | ||
| */ | ||
| declare function classifySideEffect(operation: ParsedOperation): SideEffect; | ||
| declare function isDryRunFloor(env?: NodeJS.ProcessEnv): boolean; | ||
| declare function isNoDestructiveFloor(env?: NodeJS.ProcessEnv): boolean; | ||
| /** | ||
| * Render the resolved request as a multiline curl command, suitable for | ||
| * `--dry-run` output. Headers follow a stable order (the Headers object's | ||
| * insertion order after `auth.apply`). | ||
| * | ||
| * Sensitive headers (Authorization, Cookie, api-key variants) are always | ||
| * redacted to `***` — tokens never reach stdout, even in dry-run. | ||
| */ | ||
| declare function renderCurl(prepared: PreparedRequest): string; | ||
| /** | ||
| * Wrap a string in single quotes, escaping any embedded single quotes the | ||
| * POSIX way: `'` → `'\''`. | ||
| */ | ||
| declare function shellQuote(value: string): string; | ||
| interface HttpClientConfig { | ||
@@ -16,2 +56,6 @@ baseUrl: string; | ||
| fetchOptions?: FetchWithRetryOptions; | ||
| /** Render the curl-equivalent instead of firing the request. */ | ||
| dryRun?: boolean; | ||
| /** Consent for destructive operations (DELETE or x-side-effect: destructive). */ | ||
| allowDestructive?: boolean; | ||
| } | ||
@@ -22,3 +66,61 @@ interface ExecutedRequest { | ||
| method: string; | ||
| /** True when the response is a synthetic curl preview produced by dry-run. */ | ||
| dryRun?: boolean; | ||
| /** Side-effect classification used at execution time. */ | ||
| sideEffect?: SideEffect; | ||
| } | ||
| /** | ||
| * Semantic description of the resolved request body. Used both as input to | ||
| * `fetch` (via `body` on RequestInit) and as hints for downstream renderers | ||
| * like `--dry-run` curl output. | ||
| */ | ||
| type PreparedBodyInfo = { | ||
| kind: 'none'; | ||
| } | { | ||
| kind: 'json'; | ||
| value: unknown; | ||
| contentType: string; | ||
| } | { | ||
| kind: 'urlencoded'; | ||
| pairs: Array<[string, string]>; | ||
| contentType: string; | ||
| } | { | ||
| kind: 'multipart'; | ||
| fields: MultipartField[]; | ||
| contentType: string; | ||
| } | { | ||
| kind: 'binary'; | ||
| contentType: string; | ||
| /** Original @path if the body came from a file reference. */ | ||
| filePath?: string; | ||
| /** Original filename (from @path or dataBase64 payload). */ | ||
| filename?: string; | ||
| bytes: number; | ||
| } | { | ||
| kind: 'text'; | ||
| value: string; | ||
| contentType: string; | ||
| }; | ||
| type MultipartField = { | ||
| name: string; | ||
| kind: 'value'; | ||
| value: string; | ||
| } | { | ||
| name: string; | ||
| kind: 'file'; | ||
| /** Original @path reference (for curl rendering). */ | ||
| path?: string; | ||
| filename: string; | ||
| contentType: string; | ||
| bytes: number; | ||
| }; | ||
| interface PreparedRequest { | ||
| url: URL; | ||
| method: string; | ||
| headers: Headers; | ||
| body: RequestInit['body']; | ||
| bodyInfo: PreparedBodyInfo; | ||
| operation: ParsedOperation; | ||
| } | ||
| declare class RequestError extends Error { | ||
@@ -94,2 +196,2 @@ readonly cause?: unknown | undefined; | ||
| export { type BuildCliOptions, type ExecutedRequest, type HttpClientConfig, RequestError, ValidationError, buildBundle, buildCli, buildCommandsFromSpec, executeOperation, resolveBaseUrl, resolveServerUrl, runCli }; | ||
| export { type BuildCliOptions, type ExecutedRequest, type HttpClientConfig, RequestError, SAFETY_ENV_VARS, SafetyError, type SideEffect, ValidationError, buildBundle, buildCli, buildCommandsFromSpec, classifySideEffect, executeOperation, isDryRunFloor, isNoDestructiveFloor, renderCurl, resolveBaseUrl, resolveServerUrl, runCli, shellQuote }; |
+180
-63
@@ -20,2 +20,113 @@ // src/index.ts | ||
| import { fetchWithRetry } from "dynamic-openapi-tools/utils"; | ||
| // src/http/curl.ts | ||
| var REDACTED_HEADERS = /* @__PURE__ */ new Set([ | ||
| "authorization", | ||
| "proxy-authorization", | ||
| "cookie", | ||
| "set-cookie", | ||
| "x-api-key", | ||
| "api-key" | ||
| ]); | ||
| 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) { | ||
| const safeValue = REDACTED_HEADERS.has(key.toLowerCase()) ? "***" : value; | ||
| parts.push(` -H ${shellQuote(`${key}: ${safeValue}`)}`); | ||
| } | ||
| 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/http/safety.ts | ||
| var READ_ONLY_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS", "TRACE"]); | ||
| var DESTRUCTIVE_METHODS = /* @__PURE__ */ new Set(["DELETE"]); | ||
| var SAFETY_ENV_VARS = { | ||
| dryRun: "DYNAMIC_OPENAPI_DRY_RUN", | ||
| noDestructive: "DYNAMIC_OPENAPI_NO_DESTRUCTIVE" | ||
| }; | ||
| var SafetyError = class extends Error { | ||
| constructor(message, operationId, sideEffect, reason) { | ||
| super(message); | ||
| this.operationId = operationId; | ||
| this.sideEffect = sideEffect; | ||
| this.reason = reason; | ||
| this.name = "SafetyError"; | ||
| } | ||
| operationId; | ||
| sideEffect; | ||
| reason; | ||
| }; | ||
| function classifySideEffect(operation) { | ||
| const override = readSideEffectExtension(operation); | ||
| if (override) return override; | ||
| const method = operation.method.toUpperCase(); | ||
| if (READ_ONLY_METHODS.has(method)) return "read-only"; | ||
| if (DESTRUCTIVE_METHODS.has(method)) return "destructive"; | ||
| return "write"; | ||
| } | ||
| function readSideEffectExtension(operation) { | ||
| const bag = operation; | ||
| const direct = bag["x-side-effect"]; | ||
| if (typeof direct === "string" && isSideEffect(direct)) return direct; | ||
| if (bag["x-destructive"] === true) return "destructive"; | ||
| const extensions = bag["extensions"]; | ||
| if (extensions && typeof extensions === "object") { | ||
| const ext = extensions; | ||
| const fromBag = ext["x-side-effect"]; | ||
| if (typeof fromBag === "string" && isSideEffect(fromBag)) return fromBag; | ||
| if (ext["x-destructive"] === true) return "destructive"; | ||
| } | ||
| return null; | ||
| } | ||
| function isSideEffect(value) { | ||
| return value === "read-only" || value === "write" || value === "destructive"; | ||
| } | ||
| function isDryRunFloor(env = process.env) { | ||
| return env[SAFETY_ENV_VARS.dryRun] === "1"; | ||
| } | ||
| function isNoDestructiveFloor(env = process.env) { | ||
| return env[SAFETY_ENV_VARS.noDestructive] === "1"; | ||
| } | ||
| // src/http/client.ts | ||
| var RequestError = class extends Error { | ||
@@ -136,2 +247,35 @@ constructor(message, cause) { | ||
| const prepared = await prepareRequest(operation, args, config); | ||
| const sideEffect = classifySideEffect(operation); | ||
| if (sideEffect === "destructive") { | ||
| if (isNoDestructiveFloor()) { | ||
| throw new SafetyError( | ||
| `Destructive operation "${operation.operationId}" blocked by ${SAFETY_ENV_VARS.noDestructive}=1 (floor is absolute).`, | ||
| operation.operationId, | ||
| sideEffect, | ||
| "floor-blocked" | ||
| ); | ||
| } | ||
| if (!config.allowDestructive) { | ||
| throw new SafetyError( | ||
| `Destructive operation "${operation.operationId}" requires consent. Pass --yes (CLI) or allowDestructive: true (programmatic).`, | ||
| operation.operationId, | ||
| sideEffect, | ||
| "no-consent" | ||
| ); | ||
| } | ||
| } | ||
| if (config.dryRun || isDryRunFloor()) { | ||
| const curl = renderCurl(prepared); | ||
| const previewResponse = new Response(curl + "\n", { | ||
| status: 200, | ||
| headers: { "Content-Type": "text/plain; charset=utf-8" } | ||
| }); | ||
| return { | ||
| response: previewResponse, | ||
| url: prepared.url.toString(), | ||
| method: prepared.method, | ||
| dryRun: true, | ||
| sideEffect | ||
| }; | ||
| } | ||
| const init = { | ||
@@ -158,3 +302,3 @@ method: prepared.method, | ||
| } | ||
| return { response, url: prepared.url.toString(), method: prepared.method }; | ||
| return { response, url: prepared.url.toString(), method: prepared.method, sideEffect }; | ||
| } | ||
@@ -659,51 +803,2 @@ function validateRequiredParams(operation, args) { | ||
| // 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 | ||
@@ -808,2 +903,8 @@ import { writeFile } from "fs/promises"; | ||
| default: false | ||
| }, | ||
| yes: { | ||
| short: "y", | ||
| type: "boolean", | ||
| description: "Consent to destructive operations (DELETE, x-side-effect: destructive)", | ||
| default: false | ||
| } | ||
@@ -818,8 +919,2 @@ }; | ||
| const auth = resolveAuthWithOAuth2(spec, options.authConfig, options.name); | ||
| const httpConfig = { | ||
| baseUrl, | ||
| auth, | ||
| defaultHeaders: options.defaultHeaders, | ||
| fetchOptions: options.fetchOptions | ||
| }; | ||
| const { commands, collisions } = buildCommandsFromSpec(spec, { | ||
@@ -833,14 +928,29 @@ handler: async (context, args) => { | ||
| }; | ||
| const dryRun = Boolean(args.options["dry-run"]); | ||
| const httpConfig = { | ||
| baseUrl, | ||
| auth, | ||
| defaultHeaders: options.defaultHeaders, | ||
| fetchOptions: options.fetchOptions, | ||
| dryRun: Boolean(args.options["dry-run"]), | ||
| allowDestructive: Boolean(args.options["yes"]) | ||
| }; | ||
| try { | ||
| const { response, dryRun } = await executeOperation(context.operation, merged, httpConfig); | ||
| if (dryRun) { | ||
| const prepared = await prepareRequest(context.operation, merged, httpConfig); | ||
| process.stdout.write(renderCurl(prepared)); | ||
| process.stdout.write("\n"); | ||
| process.stdout.write(await response.text()); | ||
| return; | ||
| } | ||
| const { response } = await executeOperation(context.operation, merged, httpConfig); | ||
| const code = await renderResponse(response, outputOptions); | ||
| if (code !== 0) process.exitCode = code; | ||
| } catch (error) { | ||
| if (error instanceof SafetyError) { | ||
| process.stderr.write(`safety: ${error.message} | ||
| `); | ||
| if (error.reason === "no-consent") { | ||
| process.stderr.write(` retry with --yes, or set ${SAFETY_ENV_VARS.noDestructive}=0 if a floor is in effect. | ||
| `); | ||
| } | ||
| process.exitCode = 3; | ||
| return; | ||
| } | ||
| if (error instanceof ValidationError) { | ||
@@ -918,3 +1028,3 @@ process.stderr.write(`${error.message} | ||
| for (const [key, value] of Object.entries(args.options)) { | ||
| if (key === "output" || key === "raw" || key === "verbose" || key === "dry-run" || key === "body" || key === "body-file") continue; | ||
| if (key === "output" || key === "raw" || key === "verbose" || key === "dry-run" || key === "yes" || key === "body" || key === "body-file") continue; | ||
| if (value !== void 0) merged[key] = value; | ||
@@ -971,2 +1081,4 @@ } | ||
| RequestError, | ||
| SAFETY_ENV_VARS, | ||
| SafetyError, | ||
| ValidationError, | ||
@@ -976,2 +1088,3 @@ buildBundle, | ||
| buildCommandsFromSpec, | ||
| classifySideEffect, | ||
| createOAuth2AuthCodeAuth2 as createOAuth2AuthCodeAuth, | ||
@@ -982,3 +1095,6 @@ detectOAuth2AuthCode2 as detectOAuth2AuthCode, | ||
| filterOperations2 as filterOperations, | ||
| isDryRunFloor, | ||
| isNoDestructiveFloor, | ||
| loadSpec, | ||
| renderCurl, | ||
| resolveAuth2 as resolveAuth, | ||
@@ -989,4 +1105,5 @@ resolveBaseUrl, | ||
| resolveSpec, | ||
| runCli | ||
| runCli, | ||
| shellQuote | ||
| }; | ||
| //# sourceMappingURL=index.js.map |
+1
-1
| { | ||
| "name": "dynamic-openapi-cli", | ||
| "version": "0.1.5", | ||
| "version": "0.1.7", | ||
| "description": "Transform any OpenAPI v3 spec into a fully functional CLI", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+49
-2
@@ -260,3 +260,3 @@ <div align="center"> | ||
| 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: | ||
| 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. Sensitive headers (`Authorization`, `Cookie`, `X-Api-Key` and friends) are **always redacted to `***`** so tokens never reach stdout: | ||
@@ -267,3 +267,3 @@ ```bash | ||
| -H 'accept: application/json' \ | ||
| -H 'authorization: Bearer sk-…' | ||
| -H 'authorization: ***' | ||
@@ -279,2 +279,45 @@ $ petstore create-pet --dry-run --body='{"name":"rex"}' | ||
| ### Safety: destructive consent and env-var floors | ||
| Every operation is classified by HTTP method: | ||
| | Side-effect | Methods | Behavior | | ||
| |:------------|:--------|:---------| | ||
| | `read-only` | `GET`, `HEAD`, `OPTIONS`, `TRACE` | runs without ceremony | | ||
| | `write` | `POST`, `PUT`, `PATCH` | runs without ceremony | | ||
| | `destructive` | `DELETE` (or `x-side-effect: destructive`) | requires `--yes` / `-y` | | ||
| ```bash | ||
| $ petstore delete-pet 42 | ||
| safety: Destructive operation "deletePet" requires consent. Pass --yes (CLI) or allowDestructive: true (programmatic). | ||
| $ echo $? | ||
| 3 | ||
| $ petstore delete-pet 42 --yes # consent given, fires the DELETE | ||
| $ petstore delete-pet 42 --yes --dry-run # preview the DELETE, exit 0 | ||
| ``` | ||
| Two environment variables act as hard floors — they are checked inside `executeOperation`, so they apply to programmatic callers too, not only to the CLI surface: | ||
| | Variable | Effect | | ||
| |:---------|:-------| | ||
| | `DYNAMIC_OPENAPI_DRY_RUN=1` | Forces dry-run for **every** request, ignoring flags. Safe-mode for CI smoke tests against a production spec. | | ||
| | `DYNAMIC_OPENAPI_NO_DESTRUCTIVE=1` | Rejects destructive operations even when `--yes` / `allowDestructive` is set. Absolute floor for read-only CI runners. | | ||
| Override the classification at the spec level with vendor extensions: | ||
| ```yaml | ||
| paths: | ||
| /search: | ||
| post: | ||
| operationId: searchThings | ||
| x-side-effect: read-only # POST that only reads — no --yes needed | ||
| /admin/wipe: | ||
| get: | ||
| operationId: wipeEverything | ||
| x-destructive: true # GET that's actually destructive — --yes required | ||
| ``` | ||
| Resolution order: `x-side-effect` > `x-destructive` sugar > HTTP method default. | ||
| ### Piping bodies: `--body=-`, `--body-file`, and `@path` | ||
@@ -316,2 +359,3 @@ | ||
| | `2` | Validation error or HTTP 4xx | | ||
| | `3` | Safety check failed (destructive op without `--yes`, or env-var floor blocked it) | | ||
@@ -558,2 +602,3 @@ --- | ||
| --dry-run Print the equivalent curl command instead of firing the request | ||
| -y, --yes Consent to destructive operations (DELETE / x-side-effect: destructive) | ||
@@ -589,2 +634,4 @@ Request body (for operations that accept one): | ||
| | `OPENAPI_OAUTH2_REDIRECT_URI` | Full redirect URI override | | ||
| | `DYNAMIC_OPENAPI_DRY_RUN` | When `1`, every request renders the curl equivalent and exits without firing | | ||
| | `DYNAMIC_OPENAPI_NO_DESTRUCTIVE` | When `1`, destructive operations are rejected even when `--yes` is set | | ||
@@ -591,0 +638,0 @@ --- |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
304148
10.35%2725
13.45%817
6.1%19
26.67%