Skip to content

Commit 6cfa55b

Browse files
feat(api): add generic workos api gateway (#142)
* feat(api): add generic `workos api` gateway for raw API requests 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). * feat(api): add interactive request builder and fix duplicate resolveApiKey - `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) * fix: Temporarily copy over the YAML spec * refactor(api): use @workos/openapi-spec package instead of vendored YAML 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. * chore(build): remove vendored YAML copy from postbuild The spec is now resolved from @workos/openapi-spec in node_modules at runtime via require.resolve, so the postbuild cp is unnecessary. * refactor(api): address code review findings - 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 * fix(api): handle invalid JSON body in dry-run JSON mode and add tests - 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. * fix(api): emit pure JSON in JSON mode and gracefully handle missing --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> * fix(api): emit structured error from runApiInteractive in JSON mode 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> * fix(api): respect empty bodies and require --yes for mutating JSON requests - 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> * fix(api): close remaining bugs flagged by Devin Review and CodeRabbit - 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> * fix(api): refuse JSON-mode interactive flow even in a TTY 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> * fix(api): expose --insecure-storage in help-json for the api command 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> * fix(api): tighten edge-case handling in body, path, and fetch flows 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> * fix(api): URL-encode path param values in interactive mode 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> * fix(api): resolve $ref params and dedupe path/operation overlap 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> * fix(api): switch catalog spec read to async to match CLAUDE.md 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> * fix(api): pass --api-key to interactive, require mandatory params and body Three fixes for the interactive API builder: - Thread --api-key from argv into runApiInteractive → apiInteractive so the override actually reaches the request layer - Split query param collection: always prompt for required params, gate optional ones behind a confirm - Add requestBodyRequired to the catalog; skip the "Provide a body?" confirm when the OpenAPI spec marks the body as required --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 64829e0 commit 6cfa55b

14 files changed

Lines changed: 2036 additions & 2 deletions

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,21 @@
4949
"@anthropic-ai/sdk": "^0.78.0",
5050
"@clack/core": "^1.0.1",
5151
"@clack/prompts": "1.0.1",
52+
"@hono/node-server": "^1",
5253
"@napi-rs/keyring": "^1.2.0",
5354
"@workos-inc/node": "^8.7.0",
55+
"@workos/openapi-spec": "^0.1.0",
5456
"@workos/skills": "0.5.0",
5557
"chalk": "^5.6.2",
5658
"diff": "^8.0.3",
5759
"fast-glob": "^3.3.3",
60+
"hono": "^4",
5861
"ink": "^6.8.0",
5962
"opn": "^5.4.0",
6063
"react": "^19.2.4",
6164
"semver": "^7.7.4",
6265
"uuid": "^13.0.0",
6366
"xstate": "^5.28.0",
64-
"hono": "^4",
65-
"@hono/node-server": "^1",
6667
"yaml": "^2.8.2",
6768
"yargs": "^18.0.0",
6869
"zod": "^4.3.6"

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/bin.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,88 @@ yargs(rawArgs)
430430
);
431431
return yargs.demandCommand(1, 'Please specify an env subcommand').strict();
432432
})
433+
.command(
434+
'api [endpoint] [filter]',
435+
'Make authenticated requests to the WorkOS API',
436+
(yargs) =>
437+
yargs
438+
.options(insecureStorageOption)
439+
.positional('endpoint', {
440+
type: 'string',
441+
describe: "API endpoint path (e.g. /users), or 'ls' to list endpoints",
442+
})
443+
.positional('filter', {
444+
type: 'string',
445+
describe: 'Filter keyword (used with ls)',
446+
})
447+
.option('method', {
448+
alias: 'X',
449+
type: 'string',
450+
describe: 'HTTP method (default: GET, or POST if body provided)',
451+
})
452+
.option('data', {
453+
alias: 'd',
454+
type: 'string',
455+
describe: 'JSON request body',
456+
})
457+
.option('file', {
458+
type: 'string',
459+
describe: 'Read request body from a file (or - for stdin)',
460+
})
461+
.option('include', {
462+
alias: 'i',
463+
type: 'boolean',
464+
default: false,
465+
describe: 'Show response headers',
466+
})
467+
.option('api-key', {
468+
type: 'string',
469+
describe: 'Override the API key',
470+
})
471+
.option('dry-run', {
472+
type: 'boolean',
473+
default: false,
474+
describe: 'Show the request without executing it',
475+
})
476+
.option('yes', {
477+
alias: 'y',
478+
type: 'boolean',
479+
default: false,
480+
describe: 'Skip confirmation for mutating requests',
481+
})
482+
.example('workos api ls', 'List all available endpoints')
483+
.example('workos api ls users', 'List endpoints matching "users"')
484+
.example('workos api /user_management/users', 'GET /user_management/users')
485+
.example('workos api /organizations -d \'{"name":"Acme"}\'', 'POST with a JSON body')
486+
.example('workos api /organizations/org_123 -X DELETE', 'DELETE an organization'),
487+
async (argv) => {
488+
await applyInsecureStorage(argv.insecureStorage as boolean | undefined);
489+
const endpoint = argv.endpoint as string | undefined;
490+
const filter = argv.filter as string | undefined;
491+
492+
const { runApiLs, runApiRequest, runApiInteractive } = await import('./commands/api/index.js');
493+
494+
if (!endpoint) {
495+
await runApiInteractive({ apiKey: argv.apiKey as string | undefined });
496+
return;
497+
}
498+
499+
if (endpoint === 'ls') {
500+
await runApiLs(filter);
501+
return;
502+
}
503+
504+
await runApiRequest(endpoint, {
505+
method: argv.method,
506+
data: argv.data,
507+
file: argv.file,
508+
include: argv.include,
509+
apiKey: argv.apiKey,
510+
dryRun: argv.dryRun,
511+
yes: argv.yes,
512+
});
513+
},
514+
)
433515
.command(['organization', 'org'], 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => {
434516
yargs.options({
435517
...insecureStorageOption,

0 commit comments

Comments
 (0)