Skip to content

feat(api): add generic workos api gateway#142

Open
nicknisi wants to merge 17 commits intomainfrom
feat/api-gateway
Open

feat(api): add generic workos api gateway#142
nicknisi wants to merge 17 commits intomainfrom
feat/api-gateway

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented May 4, 2026

Summary

  • Adds workos api — a generic authenticated gateway to the WorkOS API
  • Three modes: interactive builder (TTY), endpoint listing (ls), and direct requests
  • Uses @workos/openapi-spec package for endpoint discovery (no vendored YAML, no postbuild copy)

Interactive builder (workos api)

Launches a guided flow in TTY environments: category picker → endpoint picker → path param fill → query params → request body → confirmation → execute. Falls back to usage instructions in non-TTY.

Endpoint listing (workos api ls [filter])

Lists all 170 available API endpoints grouped by tag. Supports filtering by path, tag, summary, or operationId. JSON output via --json.

Direct requests (workos api <endpoint> [opts])

  • -X / --method — HTTP method (defaults to GET, or POST if body provided)
  • -d / --data — inline JSON body
  • --file — read body from file (or - for stdin)
  • -i / --include — show response headers
  • --dry-run — preview request without executing
  • -y / --yes — skip confirmation for mutating methods
  • --api-key — override the resolved API key

Auth resolves from the active environment automatically (same as existing resource commands).

Architecture

  • catalog.ts — parses the OpenAPI YAML from @workos/openapi-spec into a queryable endpoint list (cached per process)
  • request.ts — thin fetch wrapper with Bearer auth and network error handling
  • format.ts — shared colorMethod() and printResponse() helpers
  • index.ts — command handlers (ls, request, interactive routing)
  • interactive.ts — TTY interactive request builder using clack

Other changes

  • Registers api in help-json.ts for --help --json discoverability
  • Adds @workos/openapi-spec as a dependency
  • Removes stale vendored YAML copy from postbuild script

Test plan

  • workos api in a TTY — interactive builder launches, can select category/endpoint, fill params, execute
  • workos api in non-TTY (piped) — prints usage instructions
  • workos api ls — lists all endpoints grouped by tag
  • workos api ls users — filters to user-related endpoints
  • workos api ls --json — outputs JSON array
  • workos api ls nonexistent — shows "no endpoints matching" message
  • workos api /user_management/users — GET request returns users
  • workos api /organizations -d '{"name":"Test"}' — POST with confirmation prompt
  • workos api /organizations -d '{"name":"Test"}' -y — POST skipping confirmation
  • workos api /organizations --dry-run -d '{}' — prints request without executing
  • workos api /users -i — shows response headers
  • workos api /users --json — JSON output mode
  • echo '{"name":"Test"}' | workos api /organizations --file - — stdin body
  • Cancel at any interactive prompt — exits cleanly

Summary by CodeRabbit

  • New Features

    • Added a top-level "api" CLI for interactive exploration and direct authenticated API requests with endpoint filtering, method, payload (data/file/stdin), dry-run, and confirm-skip options
    • Interactive browsing with parameter prompts, pretty request previews, and formatted response display (optional status/headers, colored HTTP verbs)
  • Documentation

    • Added CLI help entries and usage examples for the new API command
  • Tests

    • Comprehensive tests for catalog parsing, formatting, command flows, and request handling

nicknisi added 3 commits May 4, 2026 15:01
Adds `workos api` command that provides direct, authenticated access to
any WorkOS API endpoint without needing a dedicated command per resource.

Three modes:
- `workos api ls [filter]` — list endpoints from the embedded OpenAPI spec
- `workos api <endpoint>` — make authenticated GET/POST/PUT/PATCH/DELETE
- Flags: -X method, -d data, --file, --include, --dry-run, --yes

The OpenAPI spec (170 endpoints, 40 tags) is embedded at build time from
the workos-openapi-spec repo. Auth resolves from the active environment
automatically (same as existing resource commands).
…piKey

- `workos api` with no args launches an interactive builder (TTY only):
  category picker → endpoint picker → path params → query params → body → confirm → execute
- Non-TTY fallback prints usage instructions
- Export colorMethod from index.ts to share with interactive.ts
- Remove duplicate resolveApiKey resolution in runApiRequest (request.ts handles the fallback)
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new top-level api CLI command and its implementation: OpenAPI catalog parsing, interactive endpoint explorer, direct API request runner (with dry-run and confirmations), response formatting, tests, help registry entry, and a minor package.json dependency reorder.

Changes

WorkOS API CLI

Layer / File(s) Summary
Data Shape / Catalog
src/commands/api/catalog.ts
Adds Param, EndpointInfo, Catalog types; implements parseSpec(yamlText) to extract endpoints/tags from an OpenAPI YAML, loadCatalog() with module-level caching, and endpointsByTag() grouping.
Request/Response Types
src/commands/api/request.ts
Adds exported ApiRequestOptions and ApiResponse interfaces describing request inputs and parsed response shape.
Core Implementation: Network I/O
src/commands/api/request.ts
Implements apiRequest(options) to resolve apiKey/baseUrl, normalize path, set Authorization/Accept headers (and conditional Content-Type), perform fetch with connection-error wrapping, parse JSON when possible, and return structured ApiResponse.
Formatting / Output
src/commands/api/format.ts
Adds colorMethod(method) to colorize HTTP verbs and printResponse(response, { includeStatus }) to print responses in human or JSON mode, optionally including status and headers.
Core Implementation: CLI Operations
src/commands/api/index.ts
Adds ApiCommandOptions, MUTATING_METHODS, and functions runApiInteractive(), runApiLs(filter?), runApiRequest(endpoint, options) including body/file/stdin resolution, method inference, dry-run modes, interactive confirmations for mutating methods, and exit-on-HTTP-error behavior.
Interactive UI
src/commands/api/interactive.ts
Implements apiInteractive(): tag/endpoint selection, prompts for path/query params and JSON body (with validation), constructs request path, confirms execution, issues apiRequest, prints formatted response, and exits with appropriate codes on cancel/error.
CLI Wiring & Help
src/bin.ts, src/utils/help-json.ts
Adds api yargs command (endpoint positional, filter optional) with options method, data, file, include, api-key, dry-run, yes; registers help-json metadata and examples.
Tests
src/commands/api/*.spec.ts
Adds Vitest suites: catalog.spec.ts (OpenAPI parsing and grouping), format.spec.ts (color/print behavior), index.spec.ts (interactive/list/request flows, dry-run, confirmations, headers, error handling), and request.spec.ts (apiRequest headers, parsing, error paths).
Minor Manifest Change
package.json
Reorders @hono/node-server earlier in dependencies; no versions/packages were added or removed.
  • Possibly related PRs:
    • workos/cli#137: Related changes to api key resolution/output behavior that could affect the new API CLI command.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding a generic workos api gateway command with authenticated API call support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/api-gateway

Comment @coderabbitai help to get the list of available commands and usage tips.

nicknisi added 3 commits May 4, 2026 17:24
Replace the 31K-line vendored OpenAPI spec with the published
@workos/openapi-spec package. The catalog now resolves the spec
via createRequire + require.resolve, keeping the same parse logic.
The spec is now resolved from @workos/openapi-spec in node_modules
at runtime via require.resolve, so the postbuild cp is unnecessary.
- Extract colorMethod + printResponse to format.ts (breaks circular import)
- Collapse PathParam/QueryParam into single Param type
- Simplify endpointsByTag to accept EndpointInfo[] directly
- Add assertNotCancelled helper (eliminates 8x cancel+cast boilerplate)
- Add network error handling in apiRequest
- resolveBody returns undefined instead of null
- Remove unnecessary comments
@nicknisi nicknisi marked this pull request as ready for review May 5, 2026 19:24
devin-ai-integration[bot]

This comment was marked as resolved.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR introduces workos api, a generic authenticated gateway command that lets users explore and call the WorkOS API directly from the CLI. It supports three modes: an interactive TTY builder, endpoint listing (ls), and direct requests with inline body, file, or stdin input.

  • Catalog parsing (catalog.ts) loads the OpenAPI spec from @workos/openapi-spec, correctly resolves $ref parameters, deduplicates path/operation-level params with operation-level taking precedence, and caches the parsed result per-process.
  • Request execution (request.ts) is a thin fetch wrapper that injects Bearer auth, parses JSON responses, and wraps network errors with a friendly message.
  • Command handlers (index.ts, interactive.ts) cover JSON-mode guards, non-interactive environment detection, --yes enforcement for mutating methods, encodeURIComponent on path and query values, and structured error codes for all failure paths.

Confidence Score: 4/5

Safe to merge; all mutating-request flows, body-error paths, $ref resolution, and URL-encoding are correctly handled. Two previously-flagged column-alignment bugs (ANSI escape codes skewing padEnd in the ls output and interactive endpoint picker) remain unresolved and will produce misaligned output whenever chalk colors are disabled.

The core request, catalog, and interactive flows are solid and well-tested. The open alignment issues affect visual output quality in no-color/piped contexts but do not break functionality or data correctness. No new logic bugs were found in this pass.

src/commands/api/index.ts and src/commands/api/interactive.ts — both still call colorMethod(...).padEnd(18), which measures ANSI escape codes as part of the string length and mis-pads when chalk is inactive.

Important Files Changed

Filename Overview
src/commands/api/catalog.ts Parses OpenAPI YAML, correctly resolves $ref params, deduplicates with operation-level precedence, and caches the catalog promise. Well-tested with $ref, dedup, and fallback cases.
src/commands/api/index.ts Main command handlers for ls, request, and interactive routing. Handles JSON mode, non-interactive guards, confirmation flow, body resolution from data/file/stdin, and structured error codes. The padEnd(18) call on a chalk-colored string may misalign columns when colors are disabled.
src/commands/api/interactive.ts TTY interactive builder with clack prompts. Path param values are now correctly URL-encoded via encodeURIComponent. Same padEnd(18) ANSI-length alignment issue as index.ts is present in the endpoint picker label.
src/commands/api/request.ts Thin fetch wrapper with Bearer auth, JSON response parsing, and network error wrapping. Clean and well-tested; no TLS bypass or credential logging.
src/commands/api/format.ts colorMethod and printResponse helpers. JSON mode emits structured output; human mode pretty-prints body and optional status/headers. No issues.
src/bin.ts Registers the api command with all options and routes endpoint/ls/interactive to the appropriate handler. Correctly applies insecure storage and lazily imports the implementation.
src/utils/help-json.ts Adds the api command entry with all options, positionals, and examples. Mirrors the bin.ts definition accurately.

Sequence Diagram

sequenceDiagram
    participant User
    participant bin.ts
    participant index.ts
    participant catalog.ts
    participant interactive.ts
    participant request.ts
    participant WorkOS API

    User->>bin.ts: workos api [endpoint] [filter]

    alt No endpoint (TTY)
        bin.ts->>index.ts: runApiInteractive()
        index.ts->>catalog.ts: loadCatalog()
        catalog.ts-->>index.ts: Catalog
        index.ts->>interactive.ts: apiInteractive()
        interactive.ts->>User: select category → endpoint → path/query params → body
        User-->>interactive.ts: inputs
        interactive.ts->>User: confirm preview
        User-->>interactive.ts: ok
        interactive.ts->>request.ts: apiRequest(...)
        request.ts->>WorkOS API: fetch(url, {Authorization: Bearer ...})
        WorkOS API-->>request.ts: Response
        request.ts-->>interactive.ts: ApiResponse
        interactive.ts->>User: printResponse (with headers)
    else endpoint = 'ls'
        bin.ts->>index.ts: runApiLs(filter?)
        index.ts->>catalog.ts: loadCatalog()
        catalog.ts-->>index.ts: Catalog
        index.ts->>User: grouped endpoint list (or JSON)
    else endpoint = '/path'
        bin.ts->>index.ts: runApiRequest(endpoint, opts)
        index.ts->>index.ts: resolveBody (--data / --file / stdin)
        opt mutating + no --yes
            index.ts->>User: confirm prompt
            User-->>index.ts: ok / cancel
        end
        index.ts->>request.ts: apiRequest(...)
        request.ts->>WorkOS API: fetch(url, {Authorization: Bearer ...})
        WorkOS API-->>request.ts: Response
        request.ts-->>index.ts: ApiResponse
        index.ts->>User: printResponse (body [+ headers])
    end
Loading

Reviews (12): Last reviewed commit: "fix(api): switch catalog spec read to as..." | Re-trigger Greptile

Comment thread src/commands/api/index.ts
Comment thread src/commands/api/index.ts
message: 'Select an endpoint:',
options: endpoints.map((e) => ({
value: e,
label: `${colorMethod(e.method).padEnd(18)} ${e.path}`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Same ANSI-padding issue as in runApiLscolorMethod(e.method).padEnd(18) pads against the chalk-escaped string length. When chalk is disabled, the label for every endpoint in the picker will have a visually oversized method column. Pad the raw string before colorizing to stay correct regardless of color mode.

Suggested change
label: `${colorMethod(e.method).padEnd(18)} ${e.path}`,
label: `${colorMethod(e.method.padEnd(6))} ${e.path}`,

Comment thread src/commands/api/index.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dffc5dde-05cf-42f1-89af-e5a0ccc4af8a

📥 Commits

Reviewing files that changed from the base of the PR and between 9179874 and 39c67aa.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (8)
  • package.json
  • src/bin.ts
  • src/commands/api/catalog.ts
  • src/commands/api/format.ts
  • src/commands/api/index.ts
  • src/commands/api/interactive.ts
  • src/commands/api/request.ts
  • src/utils/help-json.ts

Comment thread src/bin.ts
Comment thread src/commands/api/format.ts
Comment thread src/commands/api/index.ts Outdated
Comment thread src/commands/api/index.ts
Comment on lines +24 to +129
export async function runApiInteractive(): Promise<void> {
if (isNonInteractiveEnvironment()) {
console.log(
'Interactive mode requires a TTY.\n\n' +
'Usage:\n' +
' workos api <endpoint> Make an API request\n' +
' workos api ls [filter] List available endpoints\n' +
'\nExample:\n' +
' workos api /user_management/users\n' +
' workos api ls users',
);
return;
}

const { apiInteractive } = await import('./interactive.js');
await apiInteractive();
}

export function runApiLs(filter?: string): void {
const catalog = loadCatalog();
let endpoints = catalog.endpoints;

if (filter) {
const lower = filter.toLowerCase();
endpoints = endpoints.filter(
(e) =>
e.path.toLowerCase().includes(lower) ||
e.tag.toLowerCase().includes(lower) ||
e.summary.toLowerCase().includes(lower) ||
e.operationId.toLowerCase().includes(lower),
);
}

if (isJsonMode()) {
outputJson({
data: endpoints.map((e) => ({
method: e.method,
path: e.path,
summary: e.summary,
tag: e.tag,
})),
});
return;
}

if (endpoints.length === 0) {
console.log(filter ? `No endpoints matching "${filter}".` : 'No endpoints found.');
return;
}

const grouped = endpointsByTag(endpoints);

for (const [tag, eps] of grouped) {
console.log(`\n${chalk.bold(tag)}`);
for (const ep of eps) {
const method = colorMethod(ep.method).padEnd(18);
console.log(` ${method} ${ep.path} ${chalk.dim(ep.summary)}`);
}
}
console.log();
}

export async function runApiRequest(endpoint: string, options: ApiCommandOptions): Promise<void> {
const body = await resolveBody(options);
const method = (options.method ?? (body ? 'POST' : 'GET')).toUpperCase();
const baseUrl = resolveApiBaseUrl();

if (options.dryRun) {
if (isJsonMode()) {
outputJson({
dryRun: true,
method,
url: `${baseUrl}${normalizePath(endpoint)}`,
body: body ? JSON.parse(body) : undefined,
});
} else {
console.log(`${chalk.dim('[dry-run]')} ${method} ${baseUrl}${normalizePath(endpoint)}`);
if (body) prettyPrint(body);
}
return;
}

if (MUTATING_METHODS.has(method) && !options.yes && !isNonInteractiveEnvironment()) {
const clack = (await import('../../utils/clack.js')).default;
console.log(`\n${chalk.yellow('About to')} ${method} ${endpoint}`);
if (body) prettyPrint(body);
const ok = await clack.confirm({ message: 'Proceed?' });
if (!ok || clack.isCancel(ok)) {
process.exit(0);
}
}

const response = await apiRequest({
method,
path: normalizePath(endpoint),
apiKey: options.apiKey,
body,
baseUrl,
});

printResponse(response, { includeStatus: options.include });

if (response.status >= 400) {
process.exit(1);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Add adjacent specs for the new command surface.

This change adds a new command entrypoint and multiple JSON/non-TTY branches, but there’s no src/commands/api/*.spec.ts coverage in the change set. The regressions above (--json -i, non-TTY fallback, etc.) are exactly the kind of cases the repo guideline asks to lock down.

As per coding guidelines, src/commands/**/*.ts: Write .spec.ts test files alongside every command file and include JSON mode tests.

Comment thread src/commands/api/index.ts
Comment thread src/commands/api/interactive.ts
Comment on lines +52 to +84
let queryString = '';
if (ep.queryParams.length > 0) {
const wantsQuery = assertNotCancelled(
await clack.confirm({
message: `Add query parameters? (${ep.queryParams.length} available)`,
initialValue: false,
}),
);

if (wantsQuery) {
const params: string[] = [];
for (const qp of ep.queryParams) {
const label = qp.required ? `${qp.name} (required):` : `${qp.name}:`;
const value = assertNotCancelled(
await clack.text({
message: label,
placeholder: qp.description || undefined,
validate: qp.required
? (v) => {
if (!v?.trim()) return `${qp.name} is required`;
}
: undefined,
}),
);
const trimmed = value.trim();
if (trimmed) {
params.push(`${encodeURIComponent(qp.name)}=${encodeURIComponent(trimmed)}`);
}
}
if (params.length > 0) {
queryString = `?${params.join('&')}`;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t allow required query params to be skipped.

The initial “Add query parameters?” confirmation applies even when ep.queryParams contains required entries, so the builder can send requests that are invalid by construction.

💡 Suggested fix
   let queryString = '';
   if (ep.queryParams.length > 0) {
-    const wantsQuery = assertNotCancelled(
-      await clack.confirm({
-        message: `Add query parameters? (${ep.queryParams.length} available)`,
-        initialValue: false,
-      }),
-    );
+    const hasRequiredQueryParams = ep.queryParams.some((qp) => qp.required);
+    const wantsQuery =
+      hasRequiredQueryParams ||
+      assertNotCancelled(
+        await clack.confirm({
+          message: `Add query parameters? (${ep.queryParams.length} available)`,
+          initialValue: false,
+        }),
+      );
 
     if (wantsQuery) {

Comment on lines +47 to +54
const rawBody = await response.text();

let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
body = rawBody;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Treat empty responses as no body.

When response.text() returns '', this falls through to body = rawBody, so JSON mode emits "" for successful 204/empty-body responses. That is a surprising wire format for DELETE-style automation.

💡 Suggested fix
   const rawBody = await response.text();
 
   let body: unknown;
+  if (rawBody === '') {
+    body = undefined;
+  } else {
-  try {
-    body = JSON.parse(rawBody);
-  } catch {
-    body = rawBody;
-  }
+    try {
+      body = JSON.parse(rawBody);
+    } catch {
+      body = rawBody;
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const rawBody = await response.text();
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
body = rawBody;
}
const rawBody = await response.text();
let body: unknown;
if (rawBody === '') {
body = undefined;
} else {
try {
body = JSON.parse(rawBody);
} catch {
body = rawBody;
}
}

greptile-apps[bot]

This comment was marked as resolved.

- Catch JSON.parse errors in --dry-run --json path and exit with a
  structured error instead of crashing with an unhandled SyntaxError
- Add spec coverage for the new src/commands/api/* files (catalog,
  format, request, index) including JSON mode behavior

Addresses Devin Review findings on PR #142.
greptile-apps[bot]

This comment was marked as resolved.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/commands/api/request.spec.ts (1)

1-114: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Run oxfmt on this file to unblock CI.

GitHub Actions reports a formatting check failure for this file, so this change set won’t merge until it is reformatted.

src/commands/api/index.spec.ts (1)

1-301: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Run oxfmt on this spec file before merge.

CI is currently failing formatting checks for this file.

♻️ Duplicate comments (2)
src/commands/api/index.ts (2)

25-35: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Honor JSON mode in non-TTY interactive fallback.

Line 25–35 always emits human text; in JSON mode this breaks machine-readable output contracts for workos api without an endpoint.

Suggested fix
 export async function runApiInteractive(): Promise<void> {
   if (isNonInteractiveEnvironment()) {
+    if (isJsonMode()) {
+      outputJson({
+        error: {
+          code: 'tty_required',
+          message: 'Interactive mode requires a TTY.',
+        },
+        usage: ['workos api <endpoint>', 'workos api ls [filter]'],
+      });
+      return;
+    }
+
     console.log(
       'Interactive mode requires a TTY.\n\n' +

As per coding guidelines, src/commands/**/*.ts: Implement both human and JSON output modes in commands; check OutputMode usage in src/bin.ts.


2-2: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Replace synchronous file reads with async reads in resolveBody.

Line 154 uses readFileSync in an async request path, which blocks the event loop and violates the project’s no-sync-API rule.

Suggested fix
-import { readFileSync } from 'node:fs';
+import { readFile } from 'node:fs/promises';
@@
-    return readFileSync(options.file, 'utf-8');
+    return await readFile(options.file, 'utf-8');
#!/bin/bash
# Verify sync fs usage in API command files
rg -n --type=ts '\breadFileSync\s*\(' src/commands/api/index.ts src/commands/api

As per coding guidelines, src/**/*.{ts,tsx,js}: Avoid Node-specific sync APIs (crypto, fs sync) unless necessary.

Also applies to: 144-155


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ef6c14c5-9d20-43a9-93be-feec28d5bac7

📥 Commits

Reviewing files that changed from the base of the PR and between 39c67aa and add7794.

📒 Files selected for processing (5)
  • src/commands/api/catalog.spec.ts
  • src/commands/api/format.spec.ts
  • src/commands/api/index.spec.ts
  • src/commands/api/index.ts
  • src/commands/api/request.spec.ts

devin-ai-integration[bot]

This comment was marked as resolved.

…-file

- printResponse now produces a single structured JSON object on stdout
  in JSON mode (including status and headers when --include is set),
  instead of leaking human-readable status/header lines that would
  corrupt machine-readable output.
- resolveBody now uses async readFile and surfaces missing files via
  exitWithError(file_read_error) instead of throwing a raw ENOENT
  stack trace.
- Update format.spec.ts to assert pure-JSON output in JSON mode and
  add a regression test for the missing --file path.

Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/commands/api/index.ts (1)

24-35: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle non-interactive fallback in JSON mode.

When output mode is JSON, this branch still prints human usage text, which breaks machine-readable consumers in non-TTY runs.

Suggested fix
 export async function runApiInteractive(): Promise<void> {
   if (isNonInteractiveEnvironment()) {
+    if (isJsonMode()) {
+      outputJson({
+        error: {
+          code: 'tty_required',
+          message: 'Interactive mode requires a TTY.',
+        },
+        usage: ['workos api <endpoint>', 'workos api ls [filter]'],
+      });
+      return;
+    }
+
     console.log(
       'Interactive mode requires a TTY.\n\n' +
         'Usage:\n' +
         '  workos api <endpoint>        Make an API request\n' +
         '  workos api ls [filter]       List available endpoints\n' +

As per coding guidelines, src/commands/**/*.ts: Implement both human and JSON output modes in commands; check OutputMode usage in src/bin.ts.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 146fe9b6-a409-4a8c-ab4e-56d27287b9ce

📥 Commits

Reviewing files that changed from the base of the PR and between add7794 and aaaa66f.

📒 Files selected for processing (5)
  • src/commands/api/format.spec.ts
  • src/commands/api/format.ts
  • src/commands/api/index.spec.ts
  • src/commands/api/index.ts
  • src/commands/api/request.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/commands/api/format.ts

When stdout/stdin is non-interactive but --json is requested, instead
of printing a human-readable usage block to stdout (which corrupts
machine-readable consumers) we now exit with a tty_required structured
error on stderr.

Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
coderabbitai[bot]

This comment was marked as resolved.

…quests

- resolveBody/runApiRequest now treat an explicit empty string body
  the same as any other body. Previously --data '' (or piping an
  empty file) would silently fall through to GET method inference and
  drop the body from dry-run/preview/confirm paths.
- In JSON mode a mutating request without --yes now exits with a
  confirmation_required structured error instead of falling through
  to a human prompt that would corrupt machine-readable output.

Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
devin-ai-integration[bot]

This comment was marked as resolved.

- runApiRequest: refuse mutating requests without --yes in
  non-interactive human mode. Previously the control flow had a gap
  where a non-TTY environment with WORKOS_FORCE_TTY=1 (or any
  non-JSON, non-interactive setup) silently proceeded with the
  request. We now print a stderr error and exit 1 in that case so
  destructive operations are never executed implicitly.
- bin.ts: register the shared insecure-storage option on the api
  command so applyInsecureStorage actually receives the flag instead
  of failing yargs strict-mode.
- request.ts: send Content-Type: application/json whenever a body is
  defined (including the empty string), matching the new empty-body
  semantics in resolveBody.
- interactive.spec.ts: add the missing test file alongside
  interactive.ts (per CLAUDE.md), covering tag/endpoint selection,
  path/query parameter substitution, JSON body collection, cancel
  paths, and >=400 response handling.

Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
devin-ai-integration[bot]

This comment was marked as resolved.

If a user runs `workos api --json` from a real TTY, the previous
guard only fired when the environment was already non-interactive,
so the code fell through to apiInteractive() — which writes
human-readable preview lines (`console.log`) to stdout before
printResponse(), corrupting the JSON output contract.

Hoist the isJsonMode() check ahead of the TTY check so JSON mode
always exits with a structured tty_required error, regardless of
TTY status. Add a regression test for the JSON-in-TTY case and
adjust the existing JSON-mode tests so they don't queue
mockReturnValueOnce values that would never be consumed (which
leaked into later tests in the file).

Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
devin-ai-integration[bot]

This comment was marked as resolved.

The api command's yargs builder registers insecureStorageOption (so
the runtime accepts --insecure-storage), but the help-json schema
omitted it, breaking the machine-readable contract documented for
agents consuming `workos api --help --json`. Mirror the convention
used by every other credential-accessing command in help-json.ts.

Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
Comment thread src/commands/api/index.ts

async function resolveBody(options: ApiCommandOptions): Promise<string | undefined> {
if (options.data !== undefined) return options.data;
if (options.file) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In index.ts:170, when options.file === '-' and stdin has already been consumed (e.g. the process was piped and the caller already read it), what does Buffer.concat(chunks) return — an empty string — and is that silently treated as a valid body?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — that was a real bug. When --file - is used and stdin is closed without any data, the for await loop exits immediately and Buffer.concat([]).toString('utf-8') returns "". The empty string then flows through runApiRequest:

const body = await resolveBody(options);
const hasBody = body !== undefined;  // true for ""
const method = (options.method ?? (hasBody ? 'POST' : 'GET')).toUpperCase();

So the method silently flips to POST and we'd fire an empty-body POST against the WorkOS API — almost certainly not what the user intended.

Fixed in 4912d24: when stdin yields no chunks under --file -, we now exit with a structured empty_stdin_body error instead of silently treating it as a valid body. Test added in index.spec.ts.

Comment thread src/commands/api/index.ts

async function resolveBody(options: ApiCommandOptions): Promise<string | undefined> {
if (options.data !== undefined) return options.data;
if (options.file) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In index.ts:170, when options.file === '-' and stdin has already been consumed (e.g. the process was piped and the caller already read it), what does Buffer.concat(chunks) return — an empty string — and is that silently treated as a valid body?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — I traced this carefully and the answer is no, outputJson doesn't run, so we don't emit body: undefined.

exitWithError is typed : never and calls process.exit(1) synchronously:

export function exitWithError(error: { code: string; message: string; details?: unknown }): never {
  outputError(error);
  process.exit(1);
}

So the catch block terminates the process before control reaches outputJson. TypeScript's never flow analysis also marks the rest of the function unreachable from that branch.

For belt-and-suspenders: even if exitWithError somehow returned, JSON.stringify strips keys whose values are undefined, so the worst case would be {"dryRun":true,"method":"POST","url":"..."} (the body key would be omitted entirely, not present-but-undefined). The current shape stays JSON-mode-clean either way, so I left it as is.

The fragile pattern (relying on the side-effect of process.exit rather than explicit control flow) is the more interesting concern — happy to refactor toward an explicit return; after the exitWithError call if you'd prefer the intent be clearer to readers.

Comment thread src/commands/api/index.ts
method,
url: `${baseUrl}${normalizePath(endpoint)}`,
body: parsedBody,
});
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In index.ts:120, the dry-run JSON branch calls exitWithError inside a try/catch-less block when JSON.parse throws, but parsedBody is declared with let and never assigned in the catch — does outputJson then emit { dryRun: true, method, url, body: undefined } (with body key present but undefined) rather than omitting the key entirely?

Comment thread src/commands/api/catalog.ts Outdated

let cachedCatalog: Catalog | undefined;

export function loadCatalog(): Catalog {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In catalog.ts:72, cachedCatalog is a module-level variable — if loadCatalog is called concurrently (two async commands racing at startup), could two readFileSync + parseSpec calls both complete before either sets cachedCatalog, resulting in two catalog objects being built and the second one winning?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No race is possible here. loadCatalog is fully synchronous (no awaits), and JavaScript's event loop runs synchronous functions to completion atomically — even if multiple async commands "concurrently" call into it, the runtime serializes the bodies. The first caller sets cachedCatalog before any other caller can begin executing the function.

The only way you'd get the "two parses, second wins" behavior is if loadCatalog itself were async with awaits between the read and the assignment (which would yield to the scheduler and let another caller race in). Since both readFileSync and parseSpec are synchronous, that interleaving is impossible.

Worth noting though: if we ever switched to async (e.g., fs.promises.readFile), the cache would need a Promise<Catalog> sentinel to be safe. Not a concern today.

placeholder: param.description || undefined,
validate: (v) => {
if (!v?.trim()) return `${param.name} is required`;
},
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In interactive.ts:46, resolvedPath = resolvedPath.replace('{' + param.name + '}', value.trim()) uses String.replace which only replaces the first occurrence — if a path template ever repeats the same param name (unusual but valid in OpenAPI), would the second placeholder be left unreplaced and sent verbatim to the API?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — String.prototype.replace with a string first arg only replaces the first occurrence, so a hypothetical /users/{id}/links/{id} would have ended up sent to the API with {id} still literal in the second slot.

I checked the live @workos/openapi-spec and it doesn't repeat any path parameter today, so this would be a defensive fix for a future spec change rather than a current bug. Still, the cost of guarding against it is essentially zero: switched to replaceAll in 4912d24, with a test in interactive.spec.ts that uses a /users/{id}/links/{id} fixture and asserts both slots are filled.

Comment thread src/commands/api/request.ts Outdated
body: options.body,
});
} catch {
throw new Error('Failed to connect to WorkOS API. Check your internet connection.');
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In request.ts:44, the entire fetch call is wrapped in a catch that discards the original error and throws a generic connectivity message — if the failure is actually a DNS resolution error, a TLS cert error, or a timeout, is losing that detail acceptable for debugging?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — discarding the original error here is bad for debugging. A DNS failure (getaddrinfo ENOTFOUND), TLS cert mismatch (unable to verify the first certificate), or undici timeout (UND_ERR_HEADERS_TIMEOUT) are all materially different from "your wifi is off", and the user can't tell which they hit when we collapse them all into one generic message.

Fixed in 4912d24: we now preserve the underlying message in the thrown error and attach the original via { cause }, so the detail is both visible in the message and accessible programmatically:

} catch (err) {
  const detail = err instanceof Error ? err.message : String(err);
  throw new Error(`Failed to connect to WorkOS API: ${detail}`, err instanceof Error ? { cause: err } : undefined);
}

Added a regression test in request.spec.ts that verifies both the message contains getaddrinfo ENOTFOUND ... and (err as Error).cause is the original Error instance.

Three small but real fixes prompted by code-review questions:

* resolveBody: when --file - is used and stdin is empty, the previous
  code returned an empty string, which runApiRequest then treated as a
  valid body (silently flipping the inferred method to POST and firing
  an empty-body request). Now exits with a structured empty_stdin_body
  error so the user knows their pipe didn't deliver any data.

* interactive.ts: path placeholder substitution used String.replace,
  which only replaces the first occurrence. The current WorkOS spec
  doesn't reuse a parameter name within a single path, but the OpenAPI
  format permits it; switch to replaceAll so a hypothetical path like
  /users/{id}/links/{id} is fully resolved.

* request.ts: the fetch error handler discarded the original error
  entirely, leaving users staring at a generic 'check your internet
  connection' message even when the real cause was a DNS failure, TLS
  cert mismatch, or timeout. Preserve the underlying message in the
  thrown error and attach it as { cause } so it's surfaced when
  printed and accessible programmatically.

Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
Comment on lines +38 to +50
let resolvedPath = ep.path;
for (const param of ep.pathParams) {
const value = assertNotCancelled(
await clack.text({
message: `${param.name}:`,
placeholder: param.description || undefined,
validate: (v) => {
if (!v?.trim()) return `${param.name} is required`;
},
}),
);
resolvedPath = resolvedPath.replaceAll(`{${param.name}}`, value.trim());
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Path param values not URL-encoded before substitution

Path parameter values are substituted into the URL path verbatim via replaceAll, while query param values are correctly wrapped in encodeURIComponent. If a user enters a value containing a space, ?, #, or % (e.g. typing user 42 for a path ID), the resulting URL https://api.workos.com/users/user 42 is not valid and Node.js's native fetch will throw a TypeError: Invalid URL. The error is caught in apiRequest and re-thrown as "Failed to connect to WorkOS API: Invalid URL", which misleads the user into thinking there's a network problem rather than an encoding issue. The query-param path already does encodeURIComponent(trimmed) correctly — applying the same treatment to path-param values before the replaceAll call would fix this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in b843058. Path param values are now wrapped in encodeURIComponent before substitution, mirroring the query-param handling. Tests now also cover a value containing reserved characters (a/b?c#da%2Fb%3Fc%23d) so we don't regress on this. Updated the existing space-in-path-param test to reflect the new encoded behavior (user 42user%2042) instead of asserting the broken verbatim version.

devin-ai-integration Bot and others added 3 commits May 5, 2026 23:24
Greptile flagged a real P1: query param values were correctly wrapped
in encodeURIComponent, but path param values were substituted verbatim.
A user typing a value containing a space, '?', '#', '%', or '/' would
end up with an invalid URL like 'https://api.workos.com/users/user 42',
which Node's native fetch rejects with TypeError: Invalid URL — and our
catch handler in request.ts then surfaces as 'Failed to connect to
WorkOS API: Invalid URL', misleading the user into thinking it was a
network issue.

Apply encodeURIComponent to path param values before substitution and
add a regression test for reserved characters.

Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
Greptile flagged two long-standing concerns in the OpenAPI catalog
parser:

1. $ref parameters are silently dropped. The WorkOS spec doesn't use
   them today, but if it ever adopts shared parameter definitions
   under components.parameters, the prompt for that parameter would
   be skipped and the literal {param} placeholder would be sent to
   the API. Resolve refs against components.parameters (with chain
   following) and skip unresolvable refs explicitly.

2. Path-level + operation-level params with the same (name, in) pair
   were concatenated, producing duplicate prompts in interactive mode.
   Per the OpenAPI 3.x spec, operation-level params override path-level
   ones for the same (name, in). Switch to a Map keyed on "in:name"
   so the operation-level definition wins.

Tests added in catalog.spec.ts cover $ref resolution, unresolvable
refs (no placeholder leak), and path/operation override semantics.

Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
Devin Review flagged that loadCatalog() used readFileSync, violating
CLAUDE.md's 'Avoid Node-specific sync APIs (crypto, fs sync) unless
necessary' rule. Switch to fs/promises readFile and make loadCatalog
return Promise<Catalog>.

The cache now stores the in-flight Promise rather than the resolved
value, so concurrent callers reuse the same readFile/parse pass — this
also closes the (theoretical) race that Greptile and others kept
flagging earlier in this PR.

Cascading async-ification:
* runApiLs becomes async and is awaited from bin.ts.
* apiInteractive already awaited loadCatalog (was returning a sync
  Catalog before — now it gets a Promise<Catalog>, which it correctly
  awaits).

Spec file mocks switched from () => mockCatalog to async () =>
mockCatalog so they match the new return type, and the four runApiLs
tests now use await.

Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant