🚀. Socket Launch Week Day 2:Introducing Manifest Alerts.Learn more
Sign In

dynamic-openapi-cli

Package Overview
Dependencies
Maintainers
1
Versions
14
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

dynamic-openapi-cli - npm Package Compare versions

Comparing version
0.1.3
to
0.1.4
+692
-45
dist/cli.js

@@ -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 = {};

@@ -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 };

@@ -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