Conversation
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)
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a new top-level ChangesWorkOS API CLI
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
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
Greptile SummaryThis PR introduces
Confidence Score: 4/5Safe 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
|
| message: 'Select an endpoint:', | ||
| options: endpoints.map((e) => ({ | ||
| value: e, | ||
| label: `${colorMethod(e.method).padEnd(18)} ${e.path}`, |
There was a problem hiding this comment.
Same ANSI-padding issue as in
runApiLs — colorMethod(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.
| label: `${colorMethod(e.method).padEnd(18)} ${e.path}`, | |
| label: `${colorMethod(e.method.padEnd(6))} ${e.path}`, |
There was a problem hiding this comment.
Actionable comments posted: 8
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: dffc5dde-05cf-42f1-89af-e5a0ccc4af8a
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (8)
package.jsonsrc/bin.tssrc/commands/api/catalog.tssrc/commands/api/format.tssrc/commands/api/index.tssrc/commands/api/interactive.tssrc/commands/api/request.tssrc/utils/help-json.ts
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
🛠️ 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.
| 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('&')}`; | ||
| } | ||
| } |
There was a problem hiding this comment.
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) {| const rawBody = await response.text(); | ||
|
|
||
| let body: unknown; | ||
| try { | ||
| body = JSON.parse(rawBody); | ||
| } catch { | ||
| body = rawBody; | ||
| } |
There was a problem hiding this comment.
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.
| 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; | |
| } | |
| } |
- 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.
There was a problem hiding this comment.
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 winRun
oxfmton 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 winRun
oxfmton 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 winHonor 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 apiwithout 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; checkOutputModeusage insrc/bin.ts.
2-2: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick winReplace synchronous file reads with async reads in
resolveBody.Line 154 uses
readFileSyncin 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/apiAs 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
📒 Files selected for processing (5)
src/commands/api/catalog.spec.tssrc/commands/api/format.spec.tssrc/commands/api/index.spec.tssrc/commands/api/index.tssrc/commands/api/request.spec.ts
…-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>
There was a problem hiding this comment.
♻️ Duplicate comments (1)
src/commands/api/index.ts (1)
24-35:⚠️ Potential issue | 🟠 Major | ⚡ Quick winHandle 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; checkOutputModeusage insrc/bin.ts.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 146fe9b6-a409-4a8c-ab4e-56d27287b9ce
📒 Files selected for processing (5)
src/commands/api/format.spec.tssrc/commands/api/format.tssrc/commands/api/index.spec.tssrc/commands/api/index.tssrc/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>
…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>
- 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>
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>
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>
|
|
||
| async function resolveBody(options: ApiCommandOptions): Promise<string | undefined> { | ||
| if (options.data !== undefined) return options.data; | ||
| if (options.file) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
|
|
||
| async function resolveBody(options: ApiCommandOptions): Promise<string | undefined> { | ||
| if (options.data !== undefined) return options.data; | ||
| if (options.file) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| method, | ||
| url: `${baseUrl}${normalizePath(endpoint)}`, | ||
| body: parsedBody, | ||
| }); |
There was a problem hiding this comment.
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?
|
|
||
| let cachedCatalog: Catalog | undefined; | ||
|
|
||
| export function loadCatalog(): Catalog { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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`; | ||
| }, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| body: options.body, | ||
| }); | ||
| } catch { | ||
| throw new Error('Failed to connect to WorkOS API. Check your internet connection.'); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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>
| 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()); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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#d → a%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 42 → user%2042) instead of asserting the broken verbatim version.
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>
Summary
workos api— a generic authenticated gateway to the WorkOS APIls), and direct requests@workos/openapi-specpackage 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 keyAuth resolves from the active environment automatically (same as existing resource commands).
Architecture
catalog.ts— parses the OpenAPI YAML from@workos/openapi-specinto a queryable endpoint list (cached per process)request.ts— thin fetch wrapper with Bearer auth and network error handlingformat.ts— sharedcolorMethod()andprintResponse()helpersindex.ts— command handlers (ls, request, interactive routing)interactive.ts— TTY interactive request builder using clackOther changes
apiinhelp-json.tsfor--help --jsondiscoverability@workos/openapi-specas a dependencyTest plan
workos apiin a TTY — interactive builder launches, can select category/endpoint, fill params, executeworkos apiin non-TTY (piped) — prints usage instructionsworkos api ls— lists all endpoints grouped by tagworkos api ls users— filters to user-related endpointsworkos api ls --json— outputs JSON arrayworkos api ls nonexistent— shows "no endpoints matching" messageworkos api /user_management/users— GET request returns usersworkos api /organizations -d '{"name":"Test"}'— POST with confirmation promptworkos api /organizations -d '{"name":"Test"}' -y— POST skipping confirmationworkos api /organizations --dry-run -d '{}'— prints request without executingworkos api /users -i— shows response headersworkos api /users --json— JSON output modeecho '{"name":"Test"}' | workos api /organizations --file -— stdin bodySummary by CodeRabbit
New Features
Documentation
Tests