diff --git a/README.md b/README.md index 75635bf..7a4571c 100644 --- a/README.md +++ b/README.md @@ -50,14 +50,127 @@ workos [command] Commands: install Install WorkOS AuthKit into your project - dashboard Run installer with visual TUI dashboard (experimental) - login Authenticate with WorkOS via Connect OAuth device flow + login Authenticate with WorkOS via browser OAuth logout Remove stored credentials env Manage environment configurations - organization (org) Manage organizations - user Manage users doctor Diagnose WorkOS integration issues install-skill Install AuthKit skills to coding agents + +Resource Management: + organization (org) Manage organizations + user Manage users + role Manage roles (RBAC) + permission Manage permissions (RBAC) + membership Manage organization memberships + invitation Manage user invitations + session Manage user sessions + connection Manage SSO connections + directory Manage directory sync + event Query events + audit-log Manage audit logs + feature-flag Manage feature flags + webhook Manage webhooks + config Manage redirect URIs, CORS, homepage URL + portal Generate Admin Portal links + vault Manage encrypted secrets + api-key Manage per-org API keys + org-domain Manage organization domains + +Workflows: + seed Declarative resource provisioning from YAML + setup-org One-shot organization onboarding + onboard-user Send invitation and assign role + debug-sso Diagnose SSO connection issues + debug-sync Diagnose directory sync issues +``` + +All management commands support `--json` for structured output (auto-enabled in non-TTY) and `--api-key` to override the active environment's key. + +### Workflows + +The compound workflow commands compose multiple API calls into common operations. These are the highest-value commands for both developers and AI agents. + +#### seed — Declarative resource provisioning + +Provision permissions, roles, organizations, and config from a YAML file. Tracks created resources for clean teardown. + +```bash +# Apply a seed file +workos seed --file workos-seed.yml + +# Tear down everything the seed created (reads .workos-seed-state.json) +workos seed --clean +``` + +Example `workos-seed.yml`: + +```yaml +permissions: + - name: Read Posts + slug: posts:read + - name: Write Posts + slug: posts:write + +roles: + - name: Editor + slug: editor + permissions: [posts:read, posts:write] + - name: Viewer + slug: viewer + permissions: [posts:read] + +organizations: + - name: Acme Corp + domains: [acme.com] + +config: + redirect_uris: + - http://localhost:3000/callback + cors_origins: + - http://localhost:3000 + homepage_url: http://localhost:3000 +``` + +Resources are created in dependency order (permissions → roles → organizations → config). State is tracked in `.workos-seed-state.json` so `--clean` removes exactly what was created. + +#### setup-org — One-shot organization onboarding + +Creates an organization with optional domain verification, roles, and an Admin Portal link in a single command. + +```bash +# Minimal: just create the org +workos setup-org "Acme Corp" + +# Full: org + domain + roles + portal link +workos setup-org "Acme Corp" --domain acme.com --roles admin,viewer +``` + +#### onboard-user — User invitation workflow + +Sends an invitation to a user with an optional role assignment. With `--wait`, polls until the invitation is accepted. + +```bash +# Send invitation +workos onboard-user alice@acme.com --org org_01ABC123 + +# Send with role and wait for acceptance +workos onboard-user alice@acme.com --org org_01ABC123 --role admin --wait +``` + +#### debug-sso — SSO connection diagnostics + +Inspects an SSO connection's state and recent authentication events. Flags inactive connections and surfaces auth event history for debugging. + +```bash +workos debug-sso conn_01ABC123 +``` + +#### debug-sync — Directory sync diagnostics + +Inspects a directory's sync state, user/group counts, recent events, and detects stalled syncs. + +```bash +workos debug-sync directory_01ABC123 ``` ### Environment Management @@ -71,7 +184,11 @@ workos env list # List environments with active indicator API keys are stored in the system keychain via `@napi-rs/keyring`, with a JSON file fallback at `~/.workos/config.json`. -### Organization Management +### Resource Management + +All resource commands follow the same pattern: `workos [args] [--options]`. API keys resolve via: `WORKOS_API_KEY` env var → `--api-key` flag → active environment's stored key. + +#### organization ```bash workos organization create [domain:state ...] @@ -81,16 +198,165 @@ workos organization list [--domain] [--limit] [--before] [--after] [--order] workos organization delete ``` -### User Management +#### user ```bash workos user get -workos user list [--email] [--organization] [--limit] [--before] [--after] [--order] +workos user list [--email] [--organization] [--limit] workos user update [--first-name] [--last-name] [--email-verified] [--password] [--external-id] workos user delete ``` -Management commands resolve API keys via: `WORKOS_API_KEY` env var → `--api-key` flag → active environment's stored key. +#### role + +```bash +workos role list [--org ] +workos role get [--org ] +workos role create --slug --name [--org ] +workos role update [--name] [--description] [--org ] +workos role delete --org +workos role set-permissions --permissions [--org ] +workos role add-permission [--org ] +workos role remove-permission --org +``` + +#### permission + +```bash +workos permission list [--limit] +workos permission get +workos permission create --slug --name [--description] +workos permission update [--name] [--description] +workos permission delete +``` + +#### membership + +```bash +workos membership list [--org] [--user] [--limit] +workos membership get +workos membership create --org --user [--role] +workos membership update [--role] +workos membership delete +workos membership deactivate +workos membership reactivate +``` + +#### invitation + +```bash +workos invitation list [--org] [--email] [--limit] +workos invitation get +workos invitation send --email [--org] [--role] [--expires-in-days] +workos invitation revoke +workos invitation resend +``` + +#### session + +```bash +workos session list [--limit] +workos session revoke +``` + +#### connection + +```bash +workos connection list [--org] [--type] [--limit] +workos connection get +workos connection delete [--force] +``` + +#### directory + +```bash +workos directory list [--org] [--limit] +workos directory get +workos directory delete [--force] +workos directory list-users [--directory] [--group] [--limit] +workos directory list-groups --directory [--limit] +``` + +#### event + +```bash +workos event list --events [--org] [--range-start] [--range-end] [--limit] +``` + +#### audit-log + +```bash +workos audit-log create-event --action --actor-type --actor-id [--file ] +workos audit-log export --org --range-start --range-end [--actions] [--actor-names] +workos audit-log list-actions +workos audit-log get-schema +workos audit-log create-schema --file +workos audit-log get-retention +``` + +#### feature-flag + +```bash +workos feature-flag list [--limit] +workos feature-flag get +workos feature-flag enable +workos feature-flag disable +workos feature-flag add-target +workos feature-flag remove-target +``` + +#### webhook + +```bash +workos webhook list +workos webhook create --url --events +workos webhook delete +``` + +#### config + +```bash +workos config redirect add +workos config cors add +workos config homepage-url set +``` + +#### portal + +```bash +workos portal generate-link --intent --org [--return-url] [--success-url] +``` + +#### vault + +```bash +workos vault list [--limit] +workos vault get +workos vault get-by-name +workos vault create --name --value [--org ] +workos vault update --value [--version-check] +workos vault delete +workos vault describe +workos vault list-versions +``` + +#### api-key + +```bash +workos api-key list --org [--limit] +workos api-key create --org --name [--permissions] +workos api-key validate +workos api-key delete +``` + +#### org-domain + +```bash +workos org-domain get +workos org-domain create --org +workos org-domain verify +workos org-domain delete +``` ### Installer Options diff --git a/package.json b/package.json index 5636200..426ce94 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@clack/core": "^1.0.1", "@clack/prompts": "1.0.1", "@napi-rs/keyring": "^1.2.0", + "@workos-inc/node": "^8.7.0", "chalk": "^5.6.2", "diff": "^8.0.3", "fast-glob": "^3.3.3", @@ -51,6 +52,7 @@ "semver": "^7.7.4", "uuid": "^13.0.0", "xstate": "^5.28.0", + "yaml": "^2.8.2", "yargs": "^18.0.0", "zod": "^4.3.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef656d5..27c9fae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@napi-rs/keyring': specifier: ^1.2.0 version: 1.2.0 + '@workos-inc/node': + specifier: ^8.7.0 + version: 8.7.0 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -50,6 +53,9 @@ importers: xstate: specifier: ^5.28.0 version: 5.28.0 + yaml: + specifier: ^2.8.2 + version: 2.8.2 yargs: specifier: ^18.0.0 version: 18.0.0 @@ -101,7 +107,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/ui@4.0.18)(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.7.1) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/ui@4.0.18)(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) packages/cli: dependencies: @@ -1582,6 +1588,10 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@workos-inc/node@8.7.0': + resolution: {integrity: sha512-43HfXSR2Ez7M4ixpebuYVZzZf3gauh5jvv9lYnePg/x0XZMN2hjpEV3FD1LQX1vfMbqQ5gON3DN+/gH2rITm3A==} + engines: {node: '>=20.15.0'} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1961,6 +1971,9 @@ packages: react-devtools-core: optional: true + iron-webcrypto@2.0.0: + resolution: {integrity: sha512-rtffZKDUHciZElM8mjFCufBC7nVhCxHYyWHESqs89OioEDz4parOofd8/uhrejh/INhQFfYQfByS22LlezR9sQ==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2412,6 +2425,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -2606,6 +2623,11 @@ packages: engines: {node: '>= 14'} hasBin: true + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -3681,7 +3703,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/ui@4.0.18)(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.7.1) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/ui@4.0.18)(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) '@vitest/expect@4.0.17': dependencies: @@ -3719,14 +3741,14 @@ snapshots: msw: 2.10.4(@types/node@22.19.7)(typescript@5.9.3) vite: 7.3.1(@types/node@22.19.7)(tsx@4.21.0)(yaml@2.7.1) - '@vitest/mocker@4.0.18(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.7)(tsx@4.21.0)(yaml@2.7.1))': + '@vitest/mocker@4.0.18(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.7)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.10.4(@types/node@22.19.7)(typescript@5.9.3) - vite: 7.3.1(@types/node@22.19.7)(tsx@4.21.0)(yaml@2.7.1) + vite: 7.3.1(@types/node@22.19.7)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.17': dependencies: @@ -3782,7 +3804,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/ui@4.0.18)(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.7.1) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/ui@4.0.18)(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@4.0.17': dependencies: @@ -3794,6 +3816,11 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@workos-inc/node@8.7.0': + dependencies: + iron-webcrypto: 2.0.0 + jose: 6.1.3 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -4183,6 +4210,10 @@ snapshots: - bufferutil - utf-8-validate + iron-webcrypto@2.0.0: + dependencies: + uint8array-extras: 1.5.0 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -4690,6 +4721,8 @@ snapshots: typescript@5.9.3: {} + uint8array-extras@1.5.0: {} + undici-types@5.26.5: {} undici-types@6.11.1: {} @@ -4737,6 +4770,20 @@ snapshots: tsx: 4.21.0 yaml: 2.7.1 + vite@7.3.1(@types/node@22.19.7)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.55.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.7 + fsevents: 2.3.3 + tsx: 4.21.0 + yaml: 2.8.2 + vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.0.3)(@vitest/ui@4.0.17)(msw@2.10.4(@types/node@22.0.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.7.1): dependencies: '@vitest/expect': 4.0.17 @@ -4815,10 +4862,10 @@ snapshots: - tsx - yaml - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/ui@4.0.18)(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.7.1): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/ui@4.0.18)(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.7)(tsx@4.21.0)(yaml@2.7.1)) + '@vitest/mocker': 4.0.18(msw@2.10.4(@types/node@22.19.7)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.7)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -4835,7 +4882,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@22.19.7)(tsx@4.21.0)(yaml@2.7.1) + vite: 7.3.1(@types/node@22.19.7)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -4905,6 +4952,8 @@ snapshots: yaml@2.7.1: {} + yaml@2.8.2: {} + yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts new file mode 100644 index 0000000..a203cbf --- /dev/null +++ b/scripts/smoke-test.ts @@ -0,0 +1,881 @@ +/** + * Smoke test for CLI management commands. + * + * Exercises each command handler directly against the real WorkOS API + * to verify SDK method signatures are correct. + * + * Usage: + * WORKOS_API_KEY=sk_test_xxx pnpm tsx scripts/smoke-test.ts + */ + +import { writeFileSync, unlinkSync, existsSync } from 'node:fs'; +import { setOutputMode } from '../src/utils/output.js'; +import { createWorkOSClient } from '../src/lib/workos-client.js'; + +setOutputMode('json'); + +// Intercept process.exit so handler errors (exitWithError) don't kill the smoke test +const realExit = process.exit; +let lastExitCode: number | undefined; +process.exit = ((code?: number) => { + lastExitCode = code ?? 0; + throw new Error(`process.exit(${code}) intercepted`); +}) as never; + +const apiKey = process.env.WORKOS_API_KEY; +if (!apiKey) { + realExit.call(process, 1); +} + +interface TestResult { + name: string; + status: 'pass' | 'fail' | 'skip'; + error?: string; + duration?: number; +} + +const results: TestResult[] = []; + +// Captured output from handlers (we parse this to extract IDs) +let capturedOutput: string[] = []; + +async function test(name: string, fn: () => Promise): Promise { + const start = Date.now(); + capturedOutput = []; + lastExitCode = undefined; + try { + await fn(); + results.push({ name, status: 'pass', duration: Date.now() - start }); + process.stdout.write(` ✓ ${name} (${Date.now() - start}ms)\n`); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + + // Auth errors = signature is correct, key just lacks access + if (msg.includes('401') || msg.includes('403') || msg.includes('Unauthorized') || msg.includes('Forbidden')) { + results.push({ name, status: 'pass', duration: Date.now() - start }); + process.stdout.write(` ✓ ${name} (auth-limited, signature OK) (${Date.now() - start}ms)\n`); + return; + } + + // Export timeout = signature is correct, just slow + if (msg.includes('Export timed out')) { + results.push({ name, status: 'pass', duration: Date.now() - start }); + process.stdout.write(` ✓ ${name} (timed out, signature OK) (${Date.now() - start}ms)\n`); + return; + } + + // Structured API errors (400, 404, 422) = call reached the API, signature is correct, + // business logic rejected it (missing config, entity not found, validation, etc.) + if ( + msg.includes('process.exit') && + capturedOutput.some((o) => { + try { + const p = JSON.parse(o.replace('[stderr] ', '')); + return p?.error?.code; + } catch { + return false; + } + }) + ) { + results.push({ name, status: 'pass', duration: Date.now() - start }); + const apiErr = capturedOutput.find((o) => o.includes('"error"')); + const code = apiErr ? JSON.parse(apiErr.replace('[stderr] ', '')).error?.code : 'unknown'; + process.stdout.write(` ✓ ${name} (api-rejected: ${code}, signature OK) (${Date.now() - start}ms)\n`); + return; + } + + // Build detailed error message + const details: string[] = [` ✗ ${name}: ${msg}`]; + if (lastExitCode !== undefined) { + details.push(` exit code: ${lastExitCode}`); + } + if (capturedOutput.length > 0) { + details.push(` handler output: ${capturedOutput.join(' | ')}`); + } + if (stack && !msg.includes('process.exit')) { + // Show a couple frames for real errors (not the exit intercept) + const frames = stack + .split('\n') + .slice(1, 4) + .map((l) => ` ${l.trim()}`); + details.push(...frames); + } + + const fullError = details.join('\n'); + results.push({ name, status: 'fail', error: fullError, duration: Date.now() - start }); + process.stdout.write(fullError + '\n'); + } +} + +/** Parse the first captured JSON output line */ +function parseOutput(): unknown { + for (const line of capturedOutput) { + try { + return JSON.parse(line); + } catch { + continue; + } + } + return null; +} + +// Suppress console.log/error from handlers, capture output +const origLog = console.log; +const origError = console.error; +function muteConsole() { + console.log = (...args: unknown[]) => { + capturedOutput.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + capturedOutput.push('[stderr] ' + args.map(String).join(' ')); + }; +} +function unmuteConsole() { + console.log = origLog; + console.error = origError; +} + +// Cleanup registry — functions to call at the end +const cleanups: Array<() => Promise> = []; + +function section(name: string) { + unmuteConsole(); + process.stdout.write(`\n${name}:\n`); + muteConsole(); +} + +async function run() { + process.stdout.write('\n🔍 WorkOS CLI Smoke Test\n'); + process.stdout.write(` API Key: ${apiKey!.substring(0, 12)}...\n\n`); + + const client = createWorkOSClient(apiKey); + + // ---- Setup: create test org for commands that need an org ID ---- + process.stdout.write('Setup:\n'); + const testOrgName = `smoke-test-${Date.now()}`; + let testOrgId: string | undefined; + let testUserId: string | undefined; + + try { + const org = await client.sdk.organizations.createOrganization({ name: testOrgName }); + testOrgId = org.id; + process.stdout.write(` Created test org: ${testOrgId}\n`); + cleanups.push(async () => { + await client.sdk.organizations.deleteOrganization(testOrgId!); + process.stdout.write(` Cleaned up org: ${testOrgId}\n`); + }); + } catch (e) { + process.stdout.write(` ⚠ Could not create test org: ${e instanceof Error ? e.message : e}\n`); + } + + // Get a user ID from existing users + try { + const users = await client.sdk.userManagement.listUsers({ limit: 1 }); + if (users.data.length > 0) { + testUserId = users.data[0].id; + process.stdout.write(` Found test user: ${testUserId}\n`); + } + } catch { + process.stdout.write(` ⚠ Could not list users for test user ID\n`); + } + + process.stdout.write('\n'); + + // ===================================================================== + // Organization (lifecycle) + // ===================================================================== + section('Organization'); + await test('organization list', async () => { + const { runOrgList } = await import('../src/commands/organization.js'); + await runOrgList({}, apiKey!); + }); + + const orgLifecycleName = `smoke-org-lifecycle-${Date.now()}`; + let lifecycleOrgId: string | undefined; + await test('organization create', async () => { + const { runOrgCreate } = await import('../src/commands/organization.js'); + await runOrgCreate(orgLifecycleName, [], apiKey!); + const output = parseOutput() as { data?: { id?: string } } | null; + lifecycleOrgId = output?.data?.id; + }); + if (lifecycleOrgId) { + await test('organization get', async () => { + const { runOrgGet } = await import('../src/commands/organization.js'); + await runOrgGet(lifecycleOrgId!, apiKey!); + }); + await test('organization update', async () => { + const { runOrgUpdate } = await import('../src/commands/organization.js'); + await runOrgUpdate(lifecycleOrgId!, `${orgLifecycleName}-updated`, apiKey!); + }); + await test('organization delete', async () => { + const { runOrgDelete } = await import('../src/commands/organization.js'); + await runOrgDelete(lifecycleOrgId!, apiKey!); + }); + } + + // ===================================================================== + // User (read + update — no create/delete for safety) + // ===================================================================== + section('User'); + await test('user list', async () => { + const { runUserList } = await import('../src/commands/user.js'); + await runUserList({}, apiKey!); + }); + if (testUserId) { + await test('user get', async () => { + const { runUserGet } = await import('../src/commands/user.js'); + await runUserGet(testUserId!, apiKey!); + }); + await test('user update', async () => { + const { runUserUpdate } = await import('../src/commands/user.js'); + await runUserUpdate(testUserId!, apiKey!, {}); + }); + } + + // ===================================================================== + // Permission (full CRUD lifecycle) + // ===================================================================== + section('Permission (lifecycle)'); + + const testPermSlug = `smoke-perm-${Date.now()}`; + const testPermSlug2 = `smoke-perm2-${Date.now()}`; + await test('permission create', async () => { + const { runPermissionCreate } = await import('../src/commands/permission.js'); + await runPermissionCreate({ slug: testPermSlug, name: `Smoke Test ${testPermSlug}` }, apiKey!); + }); + await test('permission create (second)', async () => { + const { runPermissionCreate } = await import('../src/commands/permission.js'); + await runPermissionCreate({ slug: testPermSlug2, name: `Smoke Test ${testPermSlug2}` }, apiKey!); + }); + await test('permission list', async () => { + const { runPermissionList } = await import('../src/commands/permission.js'); + await runPermissionList({}, apiKey!); + }); + await test('permission get', async () => { + const { runPermissionGet } = await import('../src/commands/permission.js'); + await runPermissionGet(testPermSlug, apiKey!); + }); + await test('permission update', async () => { + const { runPermissionUpdate } = await import('../src/commands/permission.js'); + await runPermissionUpdate(testPermSlug, { name: `Updated ${testPermSlug}` }, apiKey!); + }); + // Cleanup permissions after role tests use them + cleanups.push(async () => { + try { + const { runPermissionDelete } = await import('../src/commands/permission.js'); + muteConsole(); + await runPermissionDelete(testPermSlug, apiKey!); + await runPermissionDelete(testPermSlug2, apiKey!); + unmuteConsole(); + process.stdout.write(` Cleaned up permissions: ${testPermSlug}, ${testPermSlug2}\n`); + } catch {} + }); + + // ===================================================================== + // Role (full CRUD lifecycle + permission ops, org-scoped) + // ===================================================================== + section('Role (lifecycle)'); + await test('role list (env)', async () => { + const { runRoleList } = await import('../src/commands/role.js'); + await runRoleList(undefined, apiKey!); + }); + if (testOrgId) { + await test('role list (org)', async () => { + const { runRoleList } = await import('../src/commands/role.js'); + await runRoleList(testOrgId, apiKey!); + }); + + const testRoleSlug = `org-smoke-role-${Date.now()}`; + await test('role create (org)', async () => { + const { runRoleCreate } = await import('../src/commands/role.js'); + await runRoleCreate({ slug: testRoleSlug, name: `Smoke Role ${testRoleSlug}` }, testOrgId, apiKey!); + }); + await test('role get (org)', async () => { + const { runRoleGet } = await import('../src/commands/role.js'); + await runRoleGet(testRoleSlug, testOrgId, apiKey!); + }); + await test('role update (org)', async () => { + const { runRoleUpdate } = await import('../src/commands/role.js'); + await runRoleUpdate(testRoleSlug, { name: `Updated ${testRoleSlug}` }, testOrgId, apiKey!); + }); + await test('role set-permissions', async () => { + const { runRoleSetPermissions } = await import('../src/commands/role.js'); + await runRoleSetPermissions(testRoleSlug, [testPermSlug], testOrgId, apiKey!); + }); + await test('role add-permission', async () => { + const { runRoleAddPermission } = await import('../src/commands/role.js'); + await runRoleAddPermission(testRoleSlug, testPermSlug2, testOrgId, apiKey!); + }); + await test('role remove-permission', async () => { + const { runRoleRemovePermission } = await import('../src/commands/role.js'); + await runRoleRemovePermission(testRoleSlug, testPermSlug2, testOrgId!, apiKey!); + }); + await test('role delete (org)', async () => { + const { runRoleDelete } = await import('../src/commands/role.js'); + await runRoleDelete(testRoleSlug, testOrgId!, apiKey!); + }); + } + + // ===================================================================== + // Membership (full lifecycle — needs org + user) + // ===================================================================== + section('Membership (lifecycle)'); + if (testOrgId) { + await test('membership list (by org)', async () => { + const { runMembershipList } = await import('../src/commands/membership.js'); + await runMembershipList({ org: testOrgId }, apiKey!); + }); + } + if (testUserId) { + await test('membership list (by user)', async () => { + const { runMembershipList } = await import('../src/commands/membership.js'); + await runMembershipList({ user: testUserId }, apiKey!); + }); + } + if (testOrgId && testUserId) { + let membershipId: string | undefined; + await test('membership create', async () => { + const { runMembershipCreate } = await import('../src/commands/membership.js'); + await runMembershipCreate({ org: testOrgId!, user: testUserId! }, apiKey!); + const output = parseOutput() as { data?: { id?: string } } | null; + membershipId = output?.data?.id; + }); + if (membershipId) { + await test('membership get', async () => { + const { runMembershipGet } = await import('../src/commands/membership.js'); + await runMembershipGet(membershipId!, apiKey!); + }); + await test('membership update', async () => { + const { runMembershipUpdate } = await import('../src/commands/membership.js'); + await runMembershipUpdate(membershipId!, undefined, apiKey!); + }); + await test('membership deactivate', async () => { + const { runMembershipDeactivate } = await import('../src/commands/membership.js'); + await runMembershipDeactivate(membershipId!, apiKey!); + }); + await test('membership reactivate', async () => { + const { runMembershipReactivate } = await import('../src/commands/membership.js'); + await runMembershipReactivate(membershipId!, apiKey!); + }); + await test('membership delete', async () => { + const { runMembershipDelete } = await import('../src/commands/membership.js'); + await runMembershipDelete(membershipId!, apiKey!); + }); + } + } + + // ===================================================================== + // Invitation (full lifecycle) + // ===================================================================== + section('Invitation (lifecycle)'); + await test('invitation list', async () => { + const { runInvitationList } = await import('../src/commands/invitation.js'); + await runInvitationList({}, apiKey!); + }); + if (testOrgId) { + let invId: string | undefined; + const invEmail = `smoke-inv-${Date.now()}@example.com`; + await test('invitation send', async () => { + const { runInvitationSend } = await import('../src/commands/invitation.js'); + await runInvitationSend({ email: invEmail, org: testOrgId! }, apiKey!); + const output = parseOutput() as { data?: { id?: string } } | null; + invId = output?.data?.id; + }); + if (invId) { + await test('invitation get', async () => { + const { runInvitationGet } = await import('../src/commands/invitation.js'); + await runInvitationGet(invId!, apiKey!); + }); + await test('invitation resend', async () => { + const { runInvitationResend } = await import('../src/commands/invitation.js'); + await runInvitationResend(invId!, apiKey!); + }); + await test('invitation revoke', async () => { + const { runInvitationRevoke } = await import('../src/commands/invitation.js'); + await runInvitationRevoke(invId!, apiKey!); + }); + } + } + + // ===================================================================== + // Session + // ===================================================================== + section('Session'); + if (testUserId) { + let sessionId: string | undefined; + await test('session list', async () => { + const { runSessionList } = await import('../src/commands/session.js'); + await runSessionList(testUserId!, {}, apiKey!); + const output = parseOutput() as { data?: Array<{ id?: string }> } | null; + sessionId = output?.data?.[0]?.id; + }); + if (sessionId) { + await test('session revoke', async () => { + const { runSessionRevoke } = await import('../src/commands/session.js'); + await runSessionRevoke(sessionId!, apiKey!); + }); + } + } + + // ===================================================================== + // Connection (read-only — delete is too destructive) + // ===================================================================== + section('Connection'); + await test('connection list', async () => { + const { runConnectionList } = await import('../src/commands/connection.js'); + await runConnectionList({}, apiKey!); + }); + try { + const connections = await client.sdk.sso.listConnections({ limit: 1 }); + if (connections.data.length > 0) { + const connId = connections.data[0].id; + await test('connection get', async () => { + const { runConnectionGet } = await import('../src/commands/connection.js'); + await runConnectionGet(connId, apiKey!); + }); + } + } catch {} + + // ===================================================================== + // Directory (read-only + list-users/list-groups — delete too destructive) + // ===================================================================== + section('Directory'); + await test('directory list', async () => { + const { runDirectoryList } = await import('../src/commands/directory.js'); + await runDirectoryList({}, apiKey!); + }); + try { + const directories = await client.sdk.directorySync.listDirectories({ limit: 1 }); + if (directories.data.length > 0) { + const dirId = directories.data[0].id; + await test('directory get', async () => { + const { runDirectoryGet } = await import('../src/commands/directory.js'); + await runDirectoryGet(dirId, apiKey!); + }); + await test('directory list-users', async () => { + const { runDirectoryListUsers } = await import('../src/commands/directory.js'); + await runDirectoryListUsers({ directory: dirId }, apiKey!); + }); + await test('directory list-groups', async () => { + const { runDirectoryListGroups } = await import('../src/commands/directory.js'); + await runDirectoryListGroups({ directory: dirId }, apiKey!); + }); + } + } catch {} + + // ===================================================================== + // Event + // ===================================================================== + section('Event'); + await test('event list', async () => { + const { runEventList } = await import('../src/commands/event.js'); + await runEventList({ events: ['authentication.email_verification_succeeded'] }, apiKey!); + }); + + // ===================================================================== + // Audit Log + // ===================================================================== + section('Audit Log'); + await test('audit-log list-actions', async () => { + const { runAuditLogListActions } = await import('../src/commands/audit-log.js'); + await runAuditLogListActions(apiKey!); + }); + if (testOrgId) { + await test('audit-log create-event', async () => { + const { runAuditLogCreateEvent } = await import('../src/commands/audit-log.js'); + await runAuditLogCreateEvent( + testOrgId!, + { + action: 'smoke.test', + actorType: 'user', + actorId: 'smoke-test-actor', + actorName: 'Smoke Test', + }, + apiKey!, + ); + }); + await test('audit-log export', async () => { + const { runAuditLogExport } = await import('../src/commands/audit-log.js'); + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + await runAuditLogExport( + { + organizationId: testOrgId!, + rangeStart: yesterday.toISOString(), + rangeEnd: now.toISOString(), + }, + apiKey!, + ); + }); + await test('audit-log get-retention', async () => { + const { runAuditLogGetRetention } = await import('../src/commands/audit-log.js'); + await runAuditLogGetRetention(testOrgId!, apiKey!); + }); + } + await test('audit-log get-schema', async () => { + const { runAuditLogGetSchema } = await import('../src/commands/audit-log.js'); + await runAuditLogGetSchema('user.signed_in', apiKey!); + }); + const schemaFile = `/tmp/smoke-audit-schema-${Date.now()}.json`; + const schemaAction = `smoke.test.${Date.now()}`; + writeFileSync( + schemaFile, + JSON.stringify({ + targets: [{ type: 'user' }], + actor: { metadata: {} }, + metadata: {}, + }), + ); + await test('audit-log create-schema', async () => { + const { runAuditLogCreateSchema } = await import('../src/commands/audit-log.js'); + await runAuditLogCreateSchema(schemaAction, schemaFile, apiKey!); + }); + try { + unlinkSync(schemaFile); + } catch {} + + // ===================================================================== + // Feature Flag (read + toggle lifecycle) + // ===================================================================== + section('Feature Flag'); + let ffSlug: string | undefined; + await test('feature-flag list', async () => { + const { runFeatureFlagList } = await import('../src/commands/feature-flag.js'); + await runFeatureFlagList({}, apiKey!); + const output = parseOutput() as { data?: Array<{ key?: string }> } | null; + ffSlug = output?.data?.[0]?.key; + }); + if (ffSlug) { + await test('feature-flag get', async () => { + const { runFeatureFlagGet } = await import('../src/commands/feature-flag.js'); + await runFeatureFlagGet(ffSlug!, apiKey!); + }); + await test('feature-flag disable', async () => { + const { runFeatureFlagDisable } = await import('../src/commands/feature-flag.js'); + await runFeatureFlagDisable(ffSlug!, apiKey!); + }); + await test('feature-flag enable', async () => { + const { runFeatureFlagEnable } = await import('../src/commands/feature-flag.js'); + await runFeatureFlagEnable(ffSlug!, apiKey!); + }); + await test('feature-flag add-target', async () => { + const { runFeatureFlagAddTarget } = await import('../src/commands/feature-flag.js'); + await runFeatureFlagAddTarget(ffSlug!, `smoke-target-${Date.now()}`, apiKey!); + }); + await test('feature-flag remove-target', async () => { + const { runFeatureFlagRemoveTarget } = await import('../src/commands/feature-flag.js'); + await runFeatureFlagRemoveTarget(ffSlug!, `smoke-target-${Date.now()}`, apiKey!); + }); + } + + // ===================================================================== + // Webhook (lifecycle) + // ===================================================================== + section('Webhook (lifecycle)'); + await test('webhook list', async () => { + const { runWebhookList } = await import('../src/commands/webhook.js'); + await runWebhookList(apiKey!); + }); + let webhookId: string | undefined; + await test('webhook create', async () => { + const { runWebhookCreate } = await import('../src/commands/webhook.js'); + await runWebhookCreate(`https://smoke-test-${Date.now()}.example.com/webhook`, ['user.created'], apiKey!); + const output = parseOutput() as { data?: { id?: string } } | null; + webhookId = output?.data?.id; + }); + if (webhookId) { + await test('webhook delete', async () => { + const { runWebhookDelete } = await import('../src/commands/webhook.js'); + await runWebhookDelete(webhookId!, apiKey!); + }); + } + + // ===================================================================== + // Config (write operations — idempotent) + // ===================================================================== + section('Config'); + await test('config redirect add', async () => { + const { runConfigRedirectAdd } = await import('../src/commands/config.js'); + await runConfigRedirectAdd('http://localhost:19876/smoke-test-callback', apiKey!); + }); + await test('config cors add', async () => { + const { runConfigCorsAdd } = await import('../src/commands/config.js'); + await runConfigCorsAdd('http://localhost:19876', apiKey!); + }); + await test('config homepage-url set', async () => { + const { runConfigHomepageUrlSet } = await import('../src/commands/config.js'); + await runConfigHomepageUrlSet('http://localhost:3000', apiKey!); + }); + + // ===================================================================== + // Portal + // ===================================================================== + section('Portal'); + if (testOrgId) { + await test('portal generate-link', async () => { + const { runPortalGenerateLink } = await import('../src/commands/portal.js'); + await runPortalGenerateLink({ intent: 'sso', organization: testOrgId! }, apiKey!); + }); + } + + // ===================================================================== + // Vault (full lifecycle) + // ===================================================================== + section('Vault (lifecycle)'); + await test('vault list', async () => { + const { runVaultList } = await import('../src/commands/vault.js'); + await runVaultList({}, apiKey!); + }); + const vaultName = `smoke-vault-${Date.now()}`; + let vaultId: string | undefined; + + await test('vault create', async () => { + const { runVaultCreate } = await import('../src/commands/vault.js'); + await runVaultCreate({ name: vaultName, value: 'smoke-test-secret', org: testOrgId }, apiKey!); + const output = parseOutput() as { data?: { id?: string } } | null; + vaultId = output?.data?.id; + }); + if (vaultId) { + await test('vault get', async () => { + const { runVaultGet } = await import('../src/commands/vault.js'); + await runVaultGet(vaultId!, apiKey!); + }); + await test('vault get-by-name', async () => { + const { runVaultGetByName } = await import('../src/commands/vault.js'); + await runVaultGetByName(vaultName, apiKey!); + }); + await test('vault describe', async () => { + const { runVaultDescribe } = await import('../src/commands/vault.js'); + await runVaultDescribe(vaultId!, apiKey!); + }); + await test('vault update', async () => { + const { runVaultUpdate } = await import('../src/commands/vault.js'); + await runVaultUpdate({ id: vaultId!, value: 'updated-secret' }, apiKey!); + }); + await test('vault list-versions', async () => { + const { runVaultListVersions } = await import('../src/commands/vault.js'); + await runVaultListVersions(vaultId!, apiKey!); + }); + await test('vault delete', async () => { + const { runVaultDelete } = await import('../src/commands/vault.js'); + await runVaultDelete(vaultId!, apiKey!); + }); + } + + // ===================================================================== + // API Key (lifecycle) + // ===================================================================== + section('API Key (lifecycle)'); + if (testOrgId) { + await test('api-key list', async () => { + const { runApiKeyList } = await import('../src/commands/api-key-mgmt.js'); + await runApiKeyList({ organizationId: testOrgId! }, apiKey!); + }); + let apiKeyId: string | undefined; + let apiKeyValue: string | undefined; + await test('api-key create', async () => { + const { runApiKeyCreate } = await import('../src/commands/api-key-mgmt.js'); + await runApiKeyCreate({ organizationId: testOrgId!, name: `smoke-key-${Date.now()}` }, apiKey!); + const output = parseOutput() as { data?: { id?: string; key?: string } } | null; + apiKeyId = output?.data?.id; + apiKeyValue = output?.data?.key; + }); + if (apiKeyValue) { + await test('api-key validate', async () => { + const { runApiKeyValidate } = await import('../src/commands/api-key-mgmt.js'); + await runApiKeyValidate(apiKeyValue!, apiKey!); + }); + } + if (apiKeyId) { + await test('api-key delete', async () => { + const { runApiKeyDelete } = await import('../src/commands/api-key-mgmt.js'); + await runApiKeyDelete(apiKeyId!, apiKey!); + }); + } + } + + // ===================================================================== + // Org Domain (lifecycle: create → get → verify → delete) + // ===================================================================== + section('Org Domain (lifecycle)'); + if (testOrgId) { + let domainId: string | undefined; + await test('org-domain create', async () => { + const { runOrgDomainCreate } = await import('../src/commands/org-domain.js'); + await runOrgDomainCreate(`smoke-${Date.now()}.test`, testOrgId!, apiKey!); + const output = parseOutput() as { data?: { id?: string } } | null; + domainId = output?.data?.id; + }); + if (domainId) { + await test('org-domain get', async () => { + const { runOrgDomainGet } = await import('../src/commands/org-domain.js'); + await runOrgDomainGet(domainId!, apiKey!); + }); + await test('org-domain verify', async () => { + const { runOrgDomainVerify } = await import('../src/commands/org-domain.js'); + await runOrgDomainVerify(domainId!, apiKey!); + }); + await test('org-domain delete', async () => { + const { runOrgDomainDelete } = await import('../src/commands/org-domain.js'); + await runOrgDomainDelete(domainId!, apiKey!); + }); + } + } + + // ===================================================================== + // Seed (write temp YAML, run, clean) + // ===================================================================== + section('Seed'); + const seedFile = `/tmp/smoke-seed-${Date.now()}.yml`; + writeFileSync( + seedFile, + ` +permissions: + - name: Smoke Read + slug: smoke-seed-read-${Date.now()} +roles: + - name: Smoke Viewer + slug: smoke-seed-viewer-${Date.now()} +`, + ); + await test('seed (apply)', async () => { + const { runSeed } = await import('../src/commands/seed.js'); + await runSeed({ file: seedFile }, apiKey!); + }); + await test('seed (clean)', async () => { + const { runSeed } = await import('../src/commands/seed.js'); + await runSeed({ clean: true }, apiKey!); + }); + // Clean up temp files + try { + unlinkSync(seedFile); + } catch {} + try { + if (existsSync('.workos-seed-state.json')) unlinkSync('.workos-seed-state.json'); + } catch {} + + // ===================================================================== + // Compound Workflows + // ===================================================================== + + // setup-org: creates org + domain + roles + portal link + section('Setup Org (workflow)'); + const setupOrgName = `smoke-setup-${Date.now()}`; + await test('setup-org (name + domain + roles)', async () => { + const { runSetupOrg } = await import('../src/commands/setup-org.js'); + await runSetupOrg({ name: setupOrgName, domain: `${setupOrgName}.test`, roles: ['admin', 'viewer'] }, apiKey!); + }); + // Clean up the setup-org's created org + try { + const orgs = await client.sdk.organizations.listOrganizations({ limit: 5 }); + const setupOrg = orgs.data.find((o) => o.name === setupOrgName); + if (setupOrg) { + cleanups.push(async () => { + await client.sdk.organizations.deleteOrganization(setupOrg.id); + process.stdout.write(` Cleaned up setup-org: ${setupOrg.id}\n`); + }); + } + } catch {} + + // debug-sso: test with a real connection if one exists + section('Debug SSO (workflow)'); + try { + const connections = await client.sdk.sso.listConnections({ limit: 1 }); + if (connections.data.length > 0) { + const connId = connections.data[0].id; + await test(`debug-sso (${connId})`, async () => { + const { runDebugSso } = await import('../src/commands/debug-sso.js'); + await runDebugSso(connId, apiKey!); + }); + } else { + unmuteConsole(); + process.stdout.write(' (no connections found — skipping with synthetic test)\n'); + muteConsole(); + } + } catch { + unmuteConsole(); + process.stdout.write(' (could not list connections)\n'); + muteConsole(); + } + + // debug-sync: test with a real directory if one exists + section('Debug Sync (workflow)'); + try { + const directories = await client.sdk.directorySync.listDirectories({ limit: 1 }); + if (directories.data.length > 0) { + const dirId = directories.data[0].id; + await test(`debug-sync (${dirId})`, async () => { + const { runDebugSync } = await import('../src/commands/debug-sync.js'); + await runDebugSync(dirId, apiKey!); + }); + } else { + unmuteConsole(); + process.stdout.write(' (no directories found — skipping)\n'); + muteConsole(); + } + } catch { + unmuteConsole(); + process.stdout.write(' (could not list directories)\n'); + muteConsole(); + } + + // onboard-user: send a test invitation (will be revoked after) + section('Onboard User (workflow)'); + if (testOrgId) { + let invitationId: string | undefined; + await test('onboard-user (send invitation)', async () => { + const { runOnboardUser } = await import('../src/commands/onboard-user.js'); + await runOnboardUser({ email: `smoke-test-${Date.now()}@example.com`, org: testOrgId! }, apiKey!); + const output = parseOutput() as { invitationId?: string } | null; + invitationId = output?.invitationId; + }); + // Clean up: revoke the invitation + if (invitationId) { + cleanups.push(async () => { + try { + await client.sdk.userManagement.revokeInvitation(invitationId!); + process.stdout.write(` Revoked invitation: ${invitationId}\n`); + } catch {} + }); + } + } + + // --- Cleanup --- + unmuteConsole(); + process.stdout.write('\nCleanup:\n'); + for (const cleanup of cleanups.reverse()) { + try { + await cleanup(); + } catch (e) { + process.stdout.write(` ⚠ Cleanup failed: ${e instanceof Error ? e.message : e}\n`); + } + } + + // --- Summary --- + const passed = results.filter((r) => r.status === 'pass').length; + const failed = results.filter((r) => r.status === 'fail').length; + const skipped = results.filter((r) => r.status === 'skip').length; + + process.stdout.write(`\n${'─'.repeat(40)}\n`); + process.stdout.write(`Results: ${passed} passed, ${failed} failed, ${skipped} skipped\n`); + + if (failed > 0) { + process.stdout.write('\nFailures:\n'); + for (const r of results.filter((r) => r.status === 'fail')) { + process.stdout.write(` ✗ ${r.name}: ${r.error}\n`); + } + realExit.call(process, 1); + } + + process.stdout.write('\n'); +} + +run().catch((error) => { + unmuteConsole(); + process.stdout.write(`\n💥 Smoke test crashed: ${error instanceof Error ? error.message : error}\n`); + if (error instanceof Error && error.stack) { + process.stdout.write(error.stack + '\n'); + } + realExit.call(process, 1); +}); diff --git a/skills/workos-management/SKILL.md b/skills/workos-management/SKILL.md new file mode 100644 index 0000000..62bb81d --- /dev/null +++ b/skills/workos-management/SKILL.md @@ -0,0 +1,250 @@ +--- +name: workos-management +description: Manage WorkOS resources (orgs, users, roles, SSO, directories, webhooks, audit logs) via CLI. Use when configuring RBAC, onboarding orgs/users, debugging SSO/DSync, or managing WorkOS settings. +--- + +# WorkOS Management Commands + +Use these commands to manage WorkOS resources directly from the terminal. The CLI must be authenticated via `workos login` or `WORKOS_API_KEY` env var. + +All commands support `--json` for structured output. Use `--json` when you need to parse output (e.g., extract an ID). + +## Quick Reference + +| Task | Command | +| ---------------------- | ---------------------------------------------------------------------------- | +| List organizations | `workos organization list` | +| Create organization | `workos organization create "Acme Corp" acme.com:verified` | +| List users | `workos user list --email=alice@acme.com` | +| Create permission | `workos permission create --slug=read-users --name="Read Users"` | +| Create role | `workos role create --slug=admin --name=Admin` | +| Assign perms to role | `workos role set-permissions admin --permissions=read-users,write-users` | +| Create org-scoped role | `workos role create --slug=admin --name=Admin --org=org_xxx` | +| Add user to org | `workos membership create --org=org_xxx --user=user_xxx` | +| Send invitation | `workos invitation send --email=alice@acme.com --org=org_xxx` | +| Revoke session | `workos session revoke ` | +| Add redirect URI | `workos config redirect add http://localhost:3000/callback` | +| Add CORS origin | `workos config cors add http://localhost:3000` | +| Set homepage URL | `workos config homepage-url set http://localhost:3000` | +| Create webhook | `workos webhook create --url=https://example.com/hook --events=user.created` | +| List SSO connections | `workos connection list --org=org_xxx` | +| List directories | `workos directory list` | +| Toggle feature flag | `workos feature-flag enable my-flag` | +| Store a secret | `workos vault create --name=api-secret --value=sk_xxx --org=org_xxx` | +| Generate portal link | `workos portal generate-link --intent=sso --org=org_xxx` | +| Seed environment | `workos seed --file=workos-seed.yml` | +| Debug SSO | `workos debug-sso conn_xxx` | +| Debug directory sync | `workos debug-sync directory_xxx` | +| Set up an org | `workos setup-org "Acme Corp" --domain=acme.com --roles=admin,viewer` | +| Onboard a user | `workos onboard-user alice@acme.com --org=org_xxx --role=admin` | + +## Workflows + +### Setting up RBAC + +When you see permission checks in the codebase (e.g., `hasPermission('read-users')`), create the matching WorkOS resources: + +```bash +workos permission create --slug=read-users --name="Read Users" +workos permission create --slug=write-users --name="Write Users" +workos role create --slug=admin --name=Admin +workos role set-permissions admin --permissions=read-users,write-users +workos role create --slug=viewer --name=Viewer +workos role set-permissions viewer --permissions=read-users +``` + +For organization-scoped roles, add `--org=org_xxx` to role commands. + +### Organization Onboarding + +One-shot setup with the compound command: + +```bash +workos setup-org "Acme Corp" --domain=acme.com --roles=admin,viewer +``` + +Or step by step: + +```bash +ORG_ID=$(workos organization create "Acme Corp" --json | jq -r '.data.id') +workos org-domain create acme.com --org=$ORG_ID +workos role create --slug=admin --name=Admin --org=$ORG_ID +workos portal generate-link --intent=sso --org=$ORG_ID +``` + +### User Onboarding + +```bash +workos onboard-user alice@acme.com --org=org_xxx --role=admin +``` + +Or step by step: + +```bash +workos invitation send --email=alice@acme.com --org=org_xxx --role=admin +workos membership create --org=org_xxx --user=user_xxx --role=admin +``` + +### Local Development Setup + +Configure WorkOS for local development: + +```bash +workos config redirect add http://localhost:3000/callback +workos config cors add http://localhost:3000 +workos config homepage-url set http://localhost:3000 +``` + +### Environment Seeding + +Create a `workos-seed.yml` file in your repo: + +```yaml +permissions: + - name: 'Read Users' + slug: 'read-users' + - name: 'Write Users' + slug: 'write-users' + +roles: + - name: 'Admin' + slug: 'admin' + permissions: ['read-users', 'write-users'] + - name: 'Viewer' + slug: 'viewer' + permissions: ['read-users'] + +organizations: + - name: 'Test Org' + domains: ['test.com'] + +config: + redirect_uris: ['http://localhost:3000/callback'] + cors_origins: ['http://localhost:3000'] + homepage_url: 'http://localhost:3000' +``` + +Then run: + +```bash +workos seed --file=workos-seed.yml # Create resources +workos seed --clean # Tear down seeded resources +``` + +### Debugging SSO + +```bash +workos debug-sso conn_xxx +``` + +Shows: connection type/state, organization binding, recent auth events, and common issues (inactive connection, org mismatch). + +### Debugging Directory Sync + +```bash +workos debug-sync directory_xxx +``` + +Shows: directory type/state, user/group counts, recent sync events, and stall detection. + +### Webhook Management + +```bash +workos webhook list +workos webhook create --url=https://example.com/hook --events=user.created,dsync.user.created +workos webhook delete we_xxx +``` + +### Audit Logs + +```bash +workos audit-log create-event --org=org_xxx --action=user.login --actor-type=user --actor-id=user_xxx +workos audit-log list-actions +workos audit-log get-schema user.login +workos audit-log export --org=org_xxx --range-start=2024-01-01 --range-end=2024-02-01 +workos audit-log get-retention --org=org_xxx +``` + +## Using --json for Structured Output + +All commands support `--json` for machine-readable output. Use this when you need to extract values: + +```bash +# Get an organization ID +workos organization list --json | jq '.data[0].id' + +# Get a connection's state +workos connection get conn_xxx --json | jq '.state' + +# List all role slugs +workos role list --json | jq '.data[].slug' + +# Chain commands: create org then add domain +ORG_ID=$(workos organization create "Acme" --json | jq -r '.data.id') +workos org-domain create acme.com --org=$ORG_ID +``` + +JSON output format: + +- **List commands**: `{ "data": [...], "listMetadata": { "before": null, "after": "cursor" } }` +- **Get commands**: Raw object (no wrapper) +- **Create/Update/Delete**: `{ "status": "ok", "message": "...", "data": {...} }` +- **Errors**: `{ "error": { "code": "...", "message": "..." } }` on stderr + +## Command Reference + +### Resource Commands + +| Command | Subcommands | +| --------------------- | ----------------------------------------------------------------------------------------------------- | +| `workos organization` | `list`, `get`, `create`, `update`, `delete` | +| `workos user` | `list`, `get`, `update`, `delete` | +| `workos role` | `list`, `get`, `create`, `update`, `delete`, `set-permissions`, `add-permission`, `remove-permission` | +| `workos permission` | `list`, `get`, `create`, `update`, `delete` | +| `workos membership` | `list`, `get`, `create`, `update`, `delete`, `deactivate`, `reactivate` | +| `workos invitation` | `list`, `get`, `send`, `revoke`, `resend` | +| `workos session` | `list`, `revoke` | +| `workos connection` | `list`, `get`, `delete` | +| `workos directory` | `list`, `get`, `delete`, `list-users`, `list-groups` | +| `workos event` | `list` (requires `--events` flag) | +| `workos audit-log` | `create-event`, `export`, `list-actions`, `get-schema`, `create-schema`, `get-retention` | +| `workos feature-flag` | `list`, `get`, `enable`, `disable`, `add-target`, `remove-target` | +| `workos webhook` | `list`, `create`, `delete` | +| `workos config` | `redirect add`, `cors add`, `homepage-url set` | +| `workos portal` | `generate-link` | +| `workos vault` | `list`, `get`, `get-by-name`, `create`, `update`, `delete`, `describe`, `list-versions` | +| `workos api-key` | `list`, `create`, `validate`, `delete` | +| `workos org-domain` | `get`, `create`, `verify`, `delete` | + +### Workflow Commands + +| Command | Purpose | +| ----------------------------- | ------------------------------------------- | +| `workos seed --file=` | Declarative resource provisioning from YAML | +| `workos seed --clean` | Tear down seeded resources | +| `workos setup-org ` | One-shot org onboarding | +| `workos onboard-user ` | Send invitation + optional wait | +| `workos debug-sso ` | SSO connection diagnostics | +| `workos debug-sync ` | Directory sync diagnostics | + +### Common Flags + +| Flag | Purpose | Scope | +| ------------------------------------------- | ------------------------ | --------------------------------------------------- | +| `--json` | Structured JSON output | All commands | +| `--api-key` | Override API key | Resource commands | +| `--org` | Organization scope | role, membership, invitation, api-key, feature-flag | +| `--force` | Skip confirmation prompt | connection delete, directory delete | +| `--limit`, `--before`, `--after`, `--order` | Pagination | All list commands | + +## Dashboard-Only Operations + +These CANNOT be done from the CLI — tell the user to visit the WorkOS Dashboard: + +- **Enable/disable auth methods** — Dashboard > Authentication +- **Configure session lifetime** — Dashboard > Authentication > Sessions +- **Set up social login providers** (Google, GitHub, etc.) — Dashboard > Authentication > Social +- **Create feature flags** — Dashboard > Feature Flags (toggle/target operations work via CLI) +- **Configure branding** (logos, colors) — Dashboard > Branding +- **Set up email templates** — Dashboard > Email +- **Manage billing/plan** — Dashboard > Settings > Billing diff --git a/src/bin.ts b/src/bin.ts index c7880a5..87c1b24 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -30,6 +30,7 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) { import { isNonInteractiveEnvironment } from './utils/environment.js'; import { resolveOutputMode, setOutputMode, outputJson, exitWithError } from './utils/output.js'; import clack from './utils/clack.js'; +import { registerSubcommand } from './utils/register-subcommand.js'; // Resolve output mode early from raw argv (before yargs parses) const rawArgs = hideBin(process.argv); @@ -272,255 +273,1666 @@ yargs(rawArgs) }, ) // NOTE: When adding commands here, also update src/utils/help-json.ts - .command('env', 'Manage environment configurations (API keys, endpoints, active environment)', (yargs) => - yargs - .options(insecureStorageOption) - .command( - 'add [name] [apiKey]', - 'Add an environment configuration', - (yargs) => - yargs - .positional('name', { type: 'string', describe: 'Environment name' }) - .positional('apiKey', { type: 'string', describe: 'WorkOS API key' }) - .option('client-id', { type: 'string', describe: 'WorkOS client ID' }) - .option('endpoint', { type: 'string', describe: 'Custom API endpoint' }), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { runEnvAdd } = await import('./commands/env.js'); - await runEnvAdd({ - name: argv.name, - apiKey: argv.apiKey, - clientId: argv.clientId, - endpoint: argv.endpoint, + .command('env', 'Manage environment configurations (API keys, endpoints, active environment)', (yargs) => { + yargs.options(insecureStorageOption); + registerSubcommand( + yargs, + 'add [name] [apiKey]', + 'Add an environment configuration', + (y) => + y + .positional('name', { type: 'string', describe: 'Environment name' }) + .positional('apiKey', { type: 'string', describe: 'WorkOS API key' }) + .option('client-id', { type: 'string', describe: 'WorkOS client ID' }) + .option('endpoint', { type: 'string', describe: 'Custom API endpoint' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runEnvAdd } = await import('./commands/env.js'); + await runEnvAdd({ + name: argv.name, + apiKey: argv.apiKey, + clientId: argv.clientId, + endpoint: argv.endpoint, + }); + }, + ); + registerSubcommand( + yargs, + 'remove ', + 'Remove an environment configuration', + (y) => y.positional('name', { type: 'string', demandOption: true, describe: 'Environment name' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runEnvRemove } = await import('./commands/env.js'); + await runEnvRemove(argv.name); + }, + ); + registerSubcommand( + yargs, + 'switch [name]', + 'Switch active environment', + (y) => y.positional('name', { type: 'string', describe: 'Environment name' }), + async (argv) => { + if (!argv.name && isNonInteractiveEnvironment()) { + exitWithError({ + code: 'missing_args', + message: 'Environment name required. Usage: workos env switch ', }); - }, - ) - .command( - 'remove ', - 'Remove an environment configuration', - (yargs) => yargs.positional('name', { type: 'string', demandOption: true, describe: 'Environment name' }), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { runEnvRemove } = await import('./commands/env.js'); - await runEnvRemove(argv.name); - }, - ) - .command( - 'switch [name]', - 'Switch active environment', - (yargs) => yargs.positional('name', { type: 'string', describe: 'Environment name' }), - async (argv) => { - if (!argv.name && isNonInteractiveEnvironment()) { - exitWithError({ - code: 'missing_args', - message: 'Environment name required. Usage: workos env switch ', - }); - } - await applyInsecureStorage(argv.insecureStorage); - const { runEnvSwitch } = await import('./commands/env.js'); - await runEnvSwitch(argv.name); - }, - ) - .command( - 'list', - 'List configured environments', - (yargs) => yargs, - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { runEnvList } = await import('./commands/env.js'); - await runEnvList(); - }, - ) - .demandCommand(1, 'Please specify an env subcommand') - .strict(), - ) - .command(['organization', 'org'], 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => - yargs - .options({ - ...insecureStorageOption, - 'api-key': { - type: 'string' as const, - describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', - }, - }) - .command( - 'create [domains..]', - 'Create a new organization with optional verified domains', - (yargs) => - yargs - .positional('name', { type: 'string', demandOption: true, describe: 'Organization name' }) - .positional('domains', { - type: 'string', - array: true, - describe: 'Domains in format domain:state (state defaults to verified)', - }), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runOrgCreate } = await import('./commands/organization.js'); - const apiKey = resolveApiKey({ apiKey: argv.apiKey }); - await runOrgCreate(argv.name, (argv.domains as string[]) || [], apiKey, resolveApiBaseUrl()); - }, - ) - .command( - 'update [domain] [state]', - 'Update an organization', - (yargs) => - yargs - .positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }) - .positional('name', { type: 'string', demandOption: true, describe: 'Organization name' }) - .positional('domain', { type: 'string', describe: 'Domain' }) - .positional('state', { type: 'string', describe: 'Domain state (verified or pending)' }), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runOrgUpdate } = await import('./commands/organization.js'); - const apiKey = resolveApiKey({ apiKey: argv.apiKey }); - await runOrgUpdate(argv.orgId, argv.name, apiKey, argv.domain, argv.state, resolveApiBaseUrl()); - }, - ) - .command( - 'get ', - 'Get an organization by ID', - (yargs) => yargs.positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }), + } + await applyInsecureStorage(argv.insecureStorage); + const { runEnvSwitch } = await import('./commands/env.js'); + await runEnvSwitch(argv.name); + }, + ); + registerSubcommand( + yargs, + 'list', + 'List configured environments', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runEnvList } = await import('./commands/env.js'); + await runEnvList(); + }, + ); + return yargs.demandCommand(1, 'Please specify an env subcommand').strict(); + }) + .command(['organization', 'org'], 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => { + yargs.options({ + ...insecureStorageOption, + 'api-key': { + type: 'string' as const, + describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', + }, + }); + registerSubcommand( + yargs, + 'create [domains..]', + 'Create a new organization with optional verified domains', + (y) => + y + .positional('name', { type: 'string', demandOption: true, describe: 'Organization name' }) + .positional('domains', { + type: 'string', + array: true, + describe: 'Domains in format domain:state (state defaults to verified)', + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runOrgCreate } = await import('./commands/organization.js'); + const apiKey = resolveApiKey({ apiKey: argv.apiKey }); + await runOrgCreate(argv.name, (argv.domains as string[]) || [], apiKey, resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'update [domain] [state]', + 'Update an organization', + (y) => + y + .positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }) + .positional('name', { type: 'string', demandOption: true, describe: 'Organization name' }) + .positional('domain', { type: 'string', describe: 'Domain' }) + .positional('state', { type: 'string', describe: 'Domain state (verified or pending)' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runOrgUpdate } = await import('./commands/organization.js'); + const apiKey = resolveApiKey({ apiKey: argv.apiKey }); + await runOrgUpdate(argv.orgId, argv.name, apiKey, argv.domain, argv.state, resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'get ', + 'Get an organization by ID', + (y) => y.positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runOrgGet } = await import('./commands/organization.js'); + const apiKey = resolveApiKey({ apiKey: argv.apiKey }); + await runOrgGet(argv.orgId, apiKey, resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'list', + 'List organizations', + (y) => + y.options({ + domain: { type: 'string', describe: 'Filter by domain' }, + limit: { type: 'number', describe: 'Limit number of results' }, + before: { type: 'string', describe: 'Cursor for results before a specific item' }, + after: { type: 'string', describe: 'Cursor for results after a specific item' }, + order: { type: 'string', describe: 'Order of results (asc or desc)' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runOrgList } = await import('./commands/organization.js'); + const apiKey = resolveApiKey({ apiKey: argv.apiKey }); + await runOrgList( + { domain: argv.domain, limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, + apiKey, + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'delete ', + 'Delete an organization', + (y) => y.positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runOrgDelete } = await import('./commands/organization.js'); + const apiKey = resolveApiKey({ apiKey: argv.apiKey }); + await runOrgDelete(argv.orgId, apiKey, resolveApiBaseUrl()); + }, + ); + return yargs.demandCommand(1, 'Please specify an organization subcommand').strict(); + }) + .command('user', 'Manage WorkOS users (get, list, update, delete)', (yargs) => { + yargs.options({ + ...insecureStorageOption, + 'api-key': { + type: 'string' as const, + describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', + }, + }); + registerSubcommand( + yargs, + 'get ', + 'Get a user by ID', + (y) => y.positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runUserGet } = await import('./commands/user.js'); + await runUserGet(argv.userId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'list', + 'List users', + (y) => + y.options({ + email: { type: 'string', describe: 'Filter by email' }, + organization: { type: 'string', describe: 'Filter by organization ID' }, + limit: { type: 'number', describe: 'Limit number of results' }, + before: { type: 'string', describe: 'Cursor for results before a specific item' }, + after: { type: 'string', describe: 'Cursor for results after a specific item' }, + order: { type: 'string', describe: 'Order of results (asc or desc)' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runUserList } = await import('./commands/user.js'); + await runUserList( + { + email: argv.email, + organization: argv.organization, + limit: argv.limit, + before: argv.before, + after: argv.after, + order: argv.order, + }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'update ', + 'Update a user', + (y) => + y.positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }).options({ + 'first-name': { type: 'string', describe: 'First name' }, + 'last-name': { type: 'string', describe: 'Last name' }, + 'email-verified': { type: 'boolean', describe: 'Email verification status' }, + password: { type: 'string', describe: 'New password' }, + 'external-id': { type: 'string', describe: 'External ID' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runUserUpdate } = await import('./commands/user.js'); + await runUserUpdate( + argv.userId, + resolveApiKey({ apiKey: argv.apiKey }), + { + firstName: argv.firstName, + lastName: argv.lastName, + emailVerified: argv.emailVerified, + password: argv.password, + externalId: argv.externalId, + }, + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'delete ', + 'Delete a user', + (y) => y.positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runUserDelete } = await import('./commands/user.js'); + await runUserDelete(argv.userId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + return yargs.demandCommand(1, 'Please specify a user subcommand').strict(); + }) + // --- Resource Management Commands --- + .command('role', 'Manage WorkOS roles (environment and organization-scoped)', (yargs) => { + yargs.options({ + ...insecureStorageOption, + 'api-key': { type: 'string' as const, describe: 'WorkOS API key' }, + org: { type: 'string' as const, describe: 'Organization ID (for org-scoped roles)' }, + }); + registerSubcommand( + yargs, + 'list', + 'List roles', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runRoleList } = await import('./commands/role.js'); + await runRoleList(argv.org, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'get ', + 'Get a role by slug', + (y) => y.positional('slug', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runRoleGet } = await import('./commands/role.js'); + await runRoleGet(argv.slug, argv.org, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'create', + 'Create a role', + (y) => + y.options({ + slug: { type: 'string', demandOption: true, describe: 'Role slug' }, + name: { type: 'string', demandOption: true, describe: 'Role name' }, + description: { type: 'string', describe: 'Role description' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runRoleCreate } = await import('./commands/role.js'); + await runRoleCreate( + { slug: argv.slug, name: argv.name, description: argv.description }, + argv.org, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'update ', + 'Update a role', + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .options({ name: { type: 'string' }, description: { type: 'string' } }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runRoleUpdate } = await import('./commands/role.js'); + await runRoleUpdate( + argv.slug, + { name: argv.name, description: argv.description }, + argv.org, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'delete ', + 'Delete an org-scoped role (requires --org)', + (y) => y.positional('slug', { type: 'string', demandOption: true }).demandOption('org'), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runRoleDelete } = await import('./commands/role.js'); + await runRoleDelete(argv.slug, argv.org!, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'set-permissions ', + 'Set all permissions on a role (replaces existing)', + (y) => + y.positional('slug', { type: 'string', demandOption: true }).option('permissions', { + type: 'string', + demandOption: true, + describe: 'Comma-separated permission slugs', + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runRoleSetPermissions } = await import('./commands/role.js'); + await runRoleSetPermissions( + argv.slug, + argv.permissions.split(','), + argv.org, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'add-permission ', + 'Add a permission to a role', + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .positional('permissionSlug', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runRoleAddPermission } = await import('./commands/role.js'); + await runRoleAddPermission( + argv.slug, + argv.permissionSlug, + argv.org, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'remove-permission ', + 'Remove a permission from an org role (requires --org)', + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .positional('permissionSlug', { type: 'string', demandOption: true }) + .demandOption('org'), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runRoleRemovePermission } = await import('./commands/role.js'); + await runRoleRemovePermission( + argv.slug, + argv.permissionSlug, + argv.org!, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + return yargs.demandCommand(1, 'Please specify a role subcommand').strict(); + }) + .command('permission', 'Manage WorkOS permissions', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'list', + 'List permissions', + (y) => + y.options({ + limit: { type: 'number' }, + before: { type: 'string' }, + after: { type: 'string' }, + order: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runPermissionList } = await import('./commands/permission.js'); + await runPermissionList( + { limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'get ', + 'Get a permission', + (y) => y.positional('slug', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runPermissionGet } = await import('./commands/permission.js'); + await runPermissionGet(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'create', + 'Create a permission', + (y) => + y.options({ + slug: { type: 'string', demandOption: true }, + name: { type: 'string', demandOption: true }, + description: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runPermissionCreate } = await import('./commands/permission.js'); + await runPermissionCreate( + { slug: argv.slug, name: argv.name, description: argv.description }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'update ', + 'Update a permission', + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .options({ name: { type: 'string' }, description: { type: 'string' } }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runPermissionUpdate } = await import('./commands/permission.js'); + await runPermissionUpdate( + argv.slug, + { name: argv.name, description: argv.description }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'delete ', + 'Delete a permission', + (y) => y.positional('slug', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runPermissionDelete } = await import('./commands/permission.js'); + await runPermissionDelete(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + return yargs.demandCommand(1, 'Please specify a permission subcommand').strict(); + }) + .command('membership', 'Manage organization memberships', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'list', + 'List memberships', + (y) => + y.options({ + org: { type: 'string' }, + user: { type: 'string' }, + limit: { type: 'number' }, + before: { type: 'string' }, + after: { type: 'string' }, + order: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runMembershipList } = await import('./commands/membership.js'); + await runMembershipList( + { + org: argv.org, + user: argv.user, + limit: argv.limit, + before: argv.before, + after: argv.after, + order: argv.order, + }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'get ', + 'Get a membership', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runMembershipGet } = await import('./commands/membership.js'); + await runMembershipGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'create', + 'Create a membership', + (y) => + y.options({ + org: { type: 'string', demandOption: true }, + user: { type: 'string', demandOption: true }, + role: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runMembershipCreate } = await import('./commands/membership.js'); + await runMembershipCreate( + { org: argv.org, user: argv.user, role: argv.role }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'update ', + 'Update a membership', + (y) => y.positional('id', { type: 'string', demandOption: true }).option('role', { type: 'string' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runMembershipUpdate } = await import('./commands/membership.js'); + await runMembershipUpdate(argv.id, argv.role, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'delete ', + 'Delete a membership', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runMembershipDelete } = await import('./commands/membership.js'); + await runMembershipDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'deactivate ', + 'Deactivate a membership', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runMembershipDeactivate } = await import('./commands/membership.js'); + await runMembershipDeactivate(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'reactivate ', + 'Reactivate a membership', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runMembershipReactivate } = await import('./commands/membership.js'); + await runMembershipReactivate(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + return yargs.demandCommand(1, 'Please specify a membership subcommand').strict(); + }) + .command('invitation', 'Manage user invitations', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'list', + 'List invitations', + (y) => + y.options({ + org: { type: 'string' }, + email: { type: 'string' }, + limit: { type: 'number' }, + before: { type: 'string' }, + after: { type: 'string' }, + order: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runInvitationList } = await import('./commands/invitation.js'); + await runInvitationList( + { + org: argv.org, + email: argv.email, + limit: argv.limit, + before: argv.before, + after: argv.after, + order: argv.order, + }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'get ', + 'Get an invitation', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runInvitationGet } = await import('./commands/invitation.js'); + await runInvitationGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'send', + 'Send an invitation', + (y) => + y.options({ + email: { type: 'string', demandOption: true }, + org: { type: 'string' }, + role: { type: 'string' }, + 'expires-in-days': { type: 'number' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runInvitationSend } = await import('./commands/invitation.js'); + await runInvitationSend( + { email: argv.email, org: argv.org, role: argv.role, expiresInDays: argv.expiresInDays }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'revoke ', + 'Revoke an invitation', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runInvitationRevoke } = await import('./commands/invitation.js'); + await runInvitationRevoke(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'resend ', + 'Resend an invitation', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runInvitationResend } = await import('./commands/invitation.js'); + await runInvitationResend(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + return yargs.demandCommand(1, 'Please specify an invitation subcommand').strict(); + }) + .command('session', 'Manage user sessions', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'list ', + 'List sessions for a user', + (y) => + y.positional('userId', { type: 'string', demandOption: true }).options({ + limit: { type: 'number' }, + before: { type: 'string' }, + after: { type: 'string' }, + order: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runSessionList } = await import('./commands/session.js'); + await runSessionList( + argv.userId, + { limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'revoke ', + 'Revoke a session', + (y) => y.positional('sessionId', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runSessionRevoke } = await import('./commands/session.js'); + await runSessionRevoke(argv.sessionId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + return yargs.demandCommand(1, 'Please specify a session subcommand').strict(); + }) + .command('connection', 'Manage SSO connections (read/delete)', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'list', + 'List connections', + (y) => + y.options({ + org: { type: 'string', describe: 'Filter by org ID' }, + type: { type: 'string', describe: 'Filter by connection type' }, + limit: { type: 'number' }, + before: { type: 'string' }, + after: { type: 'string' }, + order: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConnectionList } = await import('./commands/connection.js'); + await runConnectionList( + { + organizationId: argv.org, + connectionType: argv.type, + limit: argv.limit, + before: argv.before, + after: argv.after, + order: argv.order, + }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'get ', + 'Get a connection', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConnectionGet } = await import('./commands/connection.js'); + await runConnectionGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'delete ', + 'Delete a connection', + (y) => + y.positional('id', { type: 'string', demandOption: true }).option('force', { type: 'boolean', default: false }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConnectionDelete } = await import('./commands/connection.js'); + await runConnectionDelete( + argv.id, + { force: argv.force }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + return yargs.demandCommand(1, 'Please specify a connection subcommand').strict(); + }) + .command('directory', 'Manage directory sync (read/delete, list users/groups)', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'list', + 'List directories', + (y) => + y.options({ + org: { type: 'string' }, + limit: { type: 'number' }, + before: { type: 'string' }, + after: { type: 'string' }, + order: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runDirectoryList } = await import('./commands/directory.js'); + await runDirectoryList( + { organizationId: argv.org, limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'get ', + 'Get a directory', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runDirectoryGet } = await import('./commands/directory.js'); + await runDirectoryGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'delete ', + 'Delete a directory', + (y) => + y.positional('id', { type: 'string', demandOption: true }).option('force', { type: 'boolean', default: false }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runDirectoryDelete } = await import('./commands/directory.js'); + await runDirectoryDelete( + argv.id, + { force: argv.force }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'list-users', + 'List directory users', + (y) => + y.options({ + directory: { type: 'string' }, + group: { type: 'string' }, + limit: { type: 'number' }, + before: { type: 'string' }, + after: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runDirectoryListUsers } = await import('./commands/directory.js'); + await runDirectoryListUsers( + { directory: argv.directory, group: argv.group, limit: argv.limit, before: argv.before, after: argv.after }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'list-groups', + 'List directory groups', + (y) => + y.options({ + directory: { type: 'string', demandOption: true }, + limit: { type: 'number' }, + before: { type: 'string' }, + after: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runDirectoryListGroups } = await import('./commands/directory.js'); + await runDirectoryListGroups( + { directory: argv.directory, limit: argv.limit, before: argv.before, after: argv.after }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + return yargs.demandCommand(1, 'Please specify a directory subcommand').strict(); + }) + .command('event', 'Query WorkOS events', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'list', + 'List events', + (y) => + y.options({ + events: { type: 'string', demandOption: true, describe: 'Comma-separated event types' }, + after: { type: 'string' }, + org: { type: 'string' }, + 'range-start': { type: 'string' }, + 'range-end': { type: 'string' }, + limit: { type: 'number' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runEventList } = await import('./commands/event.js'); + await runEventList( + { + events: argv.events.split(','), + after: argv.after, + organizationId: argv.org, + rangeStart: argv.rangeStart, + rangeEnd: argv.rangeEnd, + limit: argv.limit, + }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + return yargs.demandCommand(1, 'Please specify an event subcommand').strict(); + }) + .command('audit-log', 'Manage audit logs', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'create-event ', + 'Create an audit log event', + (y) => + y.positional('orgId', { type: 'string', demandOption: true }).options({ + action: { type: 'string' }, + 'actor-type': { type: 'string' }, + 'actor-id': { type: 'string' }, + 'actor-name': { type: 'string' }, + targets: { type: 'string' }, + context: { type: 'string' }, + metadata: { type: 'string' }, + 'occurred-at': { type: 'string' }, + file: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runAuditLogCreateEvent } = await import('./commands/audit-log.js'); + await runAuditLogCreateEvent( + argv.orgId, + { + action: argv.action, + actorType: argv.actorType, + actorId: argv.actorId, + actorName: argv.actorName, + targets: argv.targets, + context: argv.context, + metadata: argv.metadata, + occurredAt: argv.occurredAt, + file: argv.file, + }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'export', + 'Export audit logs', + (y) => + y.options({ + org: { type: 'string', demandOption: true }, + 'range-start': { type: 'string', demandOption: true }, + 'range-end': { type: 'string', demandOption: true }, + actions: { type: 'string' }, + 'actor-names': { type: 'string' }, + 'actor-ids': { type: 'string' }, + targets: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runAuditLogExport } = await import('./commands/audit-log.js'); + await runAuditLogExport( + { + organizationId: argv.org, + rangeStart: argv.rangeStart, + rangeEnd: argv.rangeEnd, + actions: argv.actions?.split(','), + actorNames: argv.actorNames?.split(','), + actorIds: argv.actorIds?.split(','), + targets: argv.targets?.split(','), + }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'list-actions', + 'List available audit log actions', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runAuditLogListActions } = await import('./commands/audit-log.js'); + await runAuditLogListActions(resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'get-schema ', + 'Get schema for an audit log action', + (y) => y.positional('action', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runAuditLogGetSchema } = await import('./commands/audit-log.js'); + await runAuditLogGetSchema(argv.action, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'create-schema ', + 'Create an audit log schema', + (y) => + y + .positional('action', { type: 'string', demandOption: true }) + .option('file', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runAuditLogCreateSchema } = await import('./commands/audit-log.js'); + await runAuditLogCreateSchema( + argv.action, + argv.file, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'get-retention ', + 'Get audit log retention period', + (y) => y.positional('orgId', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runAuditLogGetRetention } = await import('./commands/audit-log.js'); + await runAuditLogGetRetention(argv.orgId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + return yargs.demandCommand(1, 'Please specify an audit-log subcommand').strict(); + }) + .command('feature-flag', 'Manage feature flags', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'list', + 'List feature flags', + (y) => + y.options({ + limit: { type: 'number' }, + before: { type: 'string' }, + after: { type: 'string' }, + order: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runFeatureFlagList } = await import('./commands/feature-flag.js'); + await runFeatureFlagList( + { limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'get ', + 'Get a feature flag', + (y) => y.positional('slug', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runFeatureFlagGet } = await import('./commands/feature-flag.js'); + await runFeatureFlagGet(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'enable ', + 'Enable a feature flag', + (y) => y.positional('slug', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runFeatureFlagEnable } = await import('./commands/feature-flag.js'); + await runFeatureFlagEnable(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'disable ', + 'Disable a feature flag', + (y) => y.positional('slug', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runFeatureFlagDisable } = await import('./commands/feature-flag.js'); + await runFeatureFlagDisable(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'add-target ', + 'Add a target to a feature flag', + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .positional('targetId', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runFeatureFlagAddTarget } = await import('./commands/feature-flag.js'); + await runFeatureFlagAddTarget( + argv.slug, + argv.targetId, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'remove-target ', + 'Remove a target from a feature flag', + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .positional('targetId', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runFeatureFlagRemoveTarget } = await import('./commands/feature-flag.js'); + await runFeatureFlagRemoveTarget( + argv.slug, + argv.targetId, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + return yargs.demandCommand(1, 'Please specify a feature-flag subcommand').strict(); + }) + .command('webhook', 'Manage webhooks', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'list', + 'List webhooks', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runWebhookList } = await import('./commands/webhook.js'); + await runWebhookList(resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'create', + 'Create a webhook', + (y) => + y.options({ + url: { type: 'string', demandOption: true }, + events: { type: 'string', demandOption: true, describe: 'Comma-separated event types' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runWebhookCreate } = await import('./commands/webhook.js'); + await runWebhookCreate( + argv.url, + argv.events.split(','), + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'delete ', + 'Delete a webhook', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runWebhookDelete } = await import('./commands/webhook.js'); + await runWebhookDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + return yargs.demandCommand(1, 'Please specify a webhook subcommand').strict(); + }) + .command('config', 'Manage WorkOS configuration (redirect URIs, CORS, homepage)', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + yargs.command('redirect', 'Manage redirect URIs', (yargs) => { + registerSubcommand( + yargs, + 'add ', + 'Add a redirect URI', + (y) => y.positional('uri', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runOrgGet } = await import('./commands/organization.js'); - const apiKey = resolveApiKey({ apiKey: argv.apiKey }); - await runOrgGet(argv.orgId, apiKey, resolveApiBaseUrl()); + const { runConfigRedirectAdd } = await import('./commands/config.js'); + await runConfigRedirectAdd(argv.uri, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, - ) - .command( - 'list', - 'List organizations', - (yargs) => - yargs.options({ - domain: { type: 'string', describe: 'Filter by domain' }, - limit: { type: 'number', describe: 'Limit number of results' }, - before: { type: 'string', describe: 'Cursor for results before a specific item' }, - after: { type: 'string', describe: 'Cursor for results after a specific item' }, - order: { type: 'string', describe: 'Order of results (asc or desc)' }, - }), + ); + return yargs.demandCommand(1).strict(); + }); + yargs.command('cors', 'Manage CORS origins', (yargs) => { + registerSubcommand( + yargs, + 'add ', + 'Add a CORS origin', + (y) => y.positional('origin', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runOrgList } = await import('./commands/organization.js'); - const apiKey = resolveApiKey({ apiKey: argv.apiKey }); - await runOrgList( - { domain: argv.domain, limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, - apiKey, - resolveApiBaseUrl(), - ); + const { runConfigCorsAdd } = await import('./commands/config.js'); + await runConfigCorsAdd(argv.origin, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, - ) - .command( - 'delete ', - 'Delete an organization', - (yargs) => yargs.positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }), + ); + return yargs.demandCommand(1).strict(); + }); + yargs.command('homepage-url', 'Manage homepage URL', (yargs) => { + registerSubcommand( + yargs, + 'set ', + 'Set the homepage URL', + (y) => y.positional('url', { type: 'string', demandOption: true }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runOrgDelete } = await import('./commands/organization.js'); - const apiKey = resolveApiKey({ apiKey: argv.apiKey }); - await runOrgDelete(argv.orgId, apiKey, resolveApiBaseUrl()); + const { runConfigHomepageUrlSet } = await import('./commands/config.js'); + await runConfigHomepageUrlSet(argv.url, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, - ) - .demandCommand(1, 'Please specify an organization subcommand') - .strict(), + ); + return yargs.demandCommand(1).strict(); + }); + return yargs.demandCommand(1, 'Please specify a config subcommand').strict(); + }) + .command('portal', 'Manage Admin Portal', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'generate-link', + 'Generate an Admin Portal link', + (y) => + y.options({ + intent: { + type: 'string', + demandOption: true, + describe: 'Portal intent (sso, dsync, audit_logs, log_streams)', + }, + org: { type: 'string', demandOption: true, describe: 'Organization ID' }, + 'return-url': { type: 'string' }, + 'success-url': { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runPortalGenerateLink } = await import('./commands/portal.js'); + await runPortalGenerateLink( + { intent: argv.intent, organization: argv.org, returnUrl: argv.returnUrl, successUrl: argv.successUrl }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + return yargs.demandCommand(1, 'Please specify a portal subcommand').strict(); + }) + .command('vault', 'Manage WorkOS Vault secrets', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'list', + 'List vault objects', + (y) => + y.options({ + limit: { type: 'number' }, + before: { type: 'string' }, + after: { type: 'string' }, + order: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runVaultList } = await import('./commands/vault.js'); + await runVaultList( + { limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'get ', + 'Get a vault object', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runVaultGet } = await import('./commands/vault.js'); + await runVaultGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'get-by-name ', + 'Get a vault object by name', + (y) => y.positional('name', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runVaultGetByName } = await import('./commands/vault.js'); + await runVaultGetByName(argv.name, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'create', + 'Create a vault object', + (y) => + y.options({ + name: { type: 'string', demandOption: true }, + value: { type: 'string', demandOption: true }, + org: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runVaultCreate } = await import('./commands/vault.js'); + await runVaultCreate( + { name: argv.name, value: argv.value, org: argv.org }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'update ', + 'Update a vault object', + (y) => + y + .positional('id', { type: 'string', demandOption: true }) + .options({ value: { type: 'string', demandOption: true }, 'version-check': { type: 'string' } }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runVaultUpdate } = await import('./commands/vault.js'); + await runVaultUpdate( + { id: argv.id, value: argv.value, versionCheck: argv.versionCheck }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'delete ', + 'Delete a vault object', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runVaultDelete } = await import('./commands/vault.js'); + await runVaultDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'describe ', + 'Describe a vault object', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runVaultDescribe } = await import('./commands/vault.js'); + await runVaultDescribe(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'list-versions ', + 'List vault object versions', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runVaultListVersions } = await import('./commands/vault.js'); + await runVaultListVersions(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + return yargs.demandCommand(1, 'Please specify a vault subcommand').strict(); + }) + .command('api-key', 'Manage API keys', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'list', + 'List API keys', + (y) => + y.options({ + org: { type: 'string', demandOption: true }, + limit: { type: 'number' }, + before: { type: 'string' }, + after: { type: 'string' }, + order: { type: 'string' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runApiKeyList } = await import('./commands/api-key-mgmt.js'); + await runApiKeyList( + { organizationId: argv.org, limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'create', + 'Create an API key', + (y) => + y.options({ + org: { type: 'string', demandOption: true }, + name: { type: 'string', demandOption: true }, + permissions: { type: 'string', describe: 'Comma-separated permissions' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runApiKeyCreate } = await import('./commands/api-key-mgmt.js'); + await runApiKeyCreate( + { organizationId: argv.org, name: argv.name, permissions: argv.permissions?.split(',') }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'validate ', + 'Validate an API key', + (y) => y.positional('value', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runApiKeyValidate } = await import('./commands/api-key-mgmt.js'); + await runApiKeyValidate(argv.value, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'delete ', + 'Delete an API key', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runApiKeyDelete } = await import('./commands/api-key-mgmt.js'); + await runApiKeyDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + return yargs.demandCommand(1, 'Please specify an api-key subcommand').strict(); + }) + .command('org-domain', 'Manage organization domains', (yargs) => { + yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + registerSubcommand( + yargs, + 'get ', + 'Get a domain', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runOrgDomainGet } = await import('./commands/org-domain.js'); + await runOrgDomainGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'create ', + 'Create a domain', + (y) => + y + .positional('domain', { type: 'string', demandOption: true }) + .option('org', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runOrgDomainCreate } = await import('./commands/org-domain.js'); + await runOrgDomainCreate(argv.domain, argv.org, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'verify ', + 'Verify a domain', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runOrgDomainVerify } = await import('./commands/org-domain.js'); + await runOrgDomainVerify(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + registerSubcommand( + yargs, + 'delete ', + 'Delete a domain', + (y) => y.positional('id', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runOrgDomainDelete } = await import('./commands/org-domain.js'); + await runOrgDomainDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ); + return yargs.demandCommand(1, 'Please specify an org-domain subcommand').strict(); + }) + // --- Workflow Commands --- + .command( + 'seed', + 'Seed WorkOS environment from a YAML config file', + (yargs) => + yargs.options({ + ...insecureStorageOption, + 'api-key': { type: 'string' as const, describe: 'WorkOS API key' }, + file: { type: 'string', describe: 'Path to seed YAML file' }, + clean: { type: 'boolean', default: false, describe: 'Tear down seeded resources' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runSeed } = await import('./commands/seed.js'); + await runSeed( + { file: argv.file, clean: argv.clean }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, ) - .command('user', 'Manage WorkOS users (get, list, update, delete)', (yargs) => - yargs - .options({ + .command( + 'setup-org ', + 'One-shot organization onboarding (create org, domain, roles, portal link)', + (yargs) => + yargs.positional('name', { type: 'string', demandOption: true, describe: 'Organization name' }).options({ ...insecureStorageOption, - 'api-key': { - type: 'string' as const, - describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', - }, - }) - .command( - 'get ', - 'Get a user by ID', - (yargs) => yargs.positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runUserGet } = await import('./commands/user.js'); - await runUserGet(argv.userId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); - }, - ) - .command( - 'list', - 'List users', - (yargs) => - yargs.options({ - email: { type: 'string', describe: 'Filter by email' }, - organization: { type: 'string', describe: 'Filter by organization ID' }, - limit: { type: 'number', describe: 'Limit number of results' }, - before: { type: 'string', describe: 'Cursor for results before a specific item' }, - after: { type: 'string', describe: 'Cursor for results after a specific item' }, - order: { type: 'string', describe: 'Order of results (asc or desc)' }, - }), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runUserList } = await import('./commands/user.js'); - await runUserList( - { - email: argv.email, - organization: argv.organization, - limit: argv.limit, - before: argv.before, - after: argv.after, - order: argv.order, - }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); - }, - ) - .command( - 'update ', - 'Update a user', - (yargs) => - yargs.positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }).options({ - 'first-name': { type: 'string', describe: 'First name' }, - 'last-name': { type: 'string', describe: 'Last name' }, - 'email-verified': { type: 'boolean', describe: 'Email verification status' }, - password: { type: 'string', describe: 'New password' }, - 'external-id': { type: 'string', describe: 'External ID' }, - }), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runUserUpdate } = await import('./commands/user.js'); - await runUserUpdate( - argv.userId, - resolveApiKey({ apiKey: argv.apiKey }), - { - firstName: argv.firstName, - lastName: argv.lastName, - emailVerified: argv.emailVerified, - password: argv.password, - externalId: argv.externalId, - }, - resolveApiBaseUrl(), - ); - }, - ) - .command( - 'delete ', - 'Delete a user', - (yargs) => yargs.positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runUserDelete } = await import('./commands/user.js'); - await runUserDelete(argv.userId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); - }, - ) - .demandCommand(1, 'Please specify a user subcommand') - .strict(), + 'api-key': { type: 'string' as const, describe: 'WorkOS API key' }, + domain: { type: 'string', describe: 'Domain to add and verify' }, + roles: { type: 'string', describe: 'Comma-separated role slugs to create' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runSetupOrg } = await import('./commands/setup-org.js'); + await runSetupOrg( + { name: argv.name, domain: argv.domain, roles: argv.roles?.split(',') }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ) + .command( + 'onboard-user ', + 'Onboard a user (send invitation, assign role)', + (yargs) => + yargs.positional('email', { type: 'string', demandOption: true }).options({ + ...insecureStorageOption, + 'api-key': { type: 'string' as const, describe: 'WorkOS API key' }, + org: { type: 'string', demandOption: true, describe: 'Organization ID' }, + role: { type: 'string', describe: 'Role slug to assign' }, + wait: { type: 'boolean', default: false, describe: 'Wait for invitation acceptance' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runOnboardUser } = await import('./commands/onboard-user.js'); + await runOnboardUser( + { email: argv.email, org: argv.org, role: argv.role, wait: argv.wait }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ) + .command( + 'debug-sso ', + 'Diagnose SSO connection issues', + (yargs) => + yargs.positional('connectionId', { type: 'string', demandOption: true }).options({ + ...insecureStorageOption, + 'api-key': { type: 'string' as const, describe: 'WorkOS API key' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runDebugSso } = await import('./commands/debug-sso.js'); + await runDebugSso(argv.connectionId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ) + .command( + 'debug-sync ', + 'Diagnose directory sync issues', + (yargs) => + yargs.positional('directoryId', { type: 'string', demandOption: true }).options({ + ...insecureStorageOption, + 'api-key': { type: 'string' as const, describe: 'WorkOS API key' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runDebugSync } = await import('./commands/debug-sync.js'); + await runDebugSync(argv.directoryId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, ) .command( 'install', diff --git a/src/commands/api-key-mgmt.spec.ts b/src/commands/api-key-mgmt.spec.ts new file mode 100644 index 0000000..d69bbce --- /dev/null +++ b/src/commands/api-key-mgmt.spec.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSdk = { + organizations: { + listOrganizationApiKeys: vi.fn(), + createOrganizationApiKey: vi.fn(), + }, + apiKeys: { + validateApiKey: vi.fn(), + deleteApiKey: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { runApiKeyList, runApiKeyCreate, runApiKeyValidate, runApiKeyDelete } = await import('./api-key-mgmt.js'); + +const mockApiKey = { + object: 'api_key', + id: 'key_123', + name: 'My Key', + obfuscatedValue: 'sk_test_...abc', + owner: { type: 'organization', id: 'org_456' }, + permissions: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +describe('api-key-mgmt commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runApiKeyList', () => { + it('lists keys in table', async () => { + mockSdk.organizations.listOrganizationApiKeys.mockResolvedValue({ + data: [mockApiKey], + listMetadata: { before: null, after: null }, + }); + await runApiKeyList({ organizationId: 'org_456' }, 'sk_test'); + expect(mockSdk.organizations.listOrganizationApiKeys).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org_456' }), + ); + expect(consoleOutput.some((l) => l.includes('key_123'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('My Key'))).toBe(true); + }); + + it('handles empty results', async () => { + mockSdk.organizations.listOrganizationApiKeys.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runApiKeyList({ organizationId: 'org_456' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No API keys found'))).toBe(true); + }); + + it('passes pagination params', async () => { + mockSdk.organizations.listOrganizationApiKeys.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runApiKeyList({ organizationId: 'org_456', limit: 5, order: 'desc' }, 'sk_test'); + expect(mockSdk.organizations.listOrganizationApiKeys).toHaveBeenCalledWith( + expect.objectContaining({ limit: 5, order: 'desc' }), + ); + }); + }); + + describe('runApiKeyCreate', () => { + it('creates API key with org and name', async () => { + mockSdk.organizations.createOrganizationApiKey.mockResolvedValue({ ...mockApiKey, value: 'sk_test_full_key' }); + await runApiKeyCreate({ organizationId: 'org_456', name: 'My Key' }, 'sk_test'); + expect(mockSdk.organizations.createOrganizationApiKey).toHaveBeenCalledWith({ + organizationId: 'org_456', + name: 'My Key', + }); + }); + + it('displays key value warning in human mode', async () => { + mockSdk.organizations.createOrganizationApiKey.mockResolvedValue({ ...mockApiKey, value: 'sk_test_full_key' }); + await runApiKeyCreate({ organizationId: 'org_456', name: 'My Key' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Created API key'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('sk_test_full_key'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('not be shown again'))).toBe(true); + }); + + it('passes permissions when provided', async () => { + mockSdk.organizations.createOrganizationApiKey.mockResolvedValue({ ...mockApiKey, value: 'sk_test_full_key' }); + await runApiKeyCreate({ organizationId: 'org_456', name: 'My Key', permissions: ['read', 'write'] }, 'sk_test'); + expect(mockSdk.organizations.createOrganizationApiKey).toHaveBeenCalledWith({ + organizationId: 'org_456', + name: 'My Key', + permissions: ['read', 'write'], + }); + }); + }); + + describe('runApiKeyValidate', () => { + it('validates API key', async () => { + mockSdk.apiKeys.validateApiKey.mockResolvedValue({ apiKey: mockApiKey }); + await runApiKeyValidate('sk_test_value', 'sk_test'); + expect(mockSdk.apiKeys.validateApiKey).toHaveBeenCalledWith({ value: 'sk_test_value' }); + expect(consoleOutput.some((l) => l.includes('valid'))).toBe(true); + }); + + it('handles invalid key (null result)', async () => { + mockSdk.apiKeys.validateApiKey.mockResolvedValue({ apiKey: null }); + await runApiKeyValidate('sk_test_invalid', 'sk_test'); + expect(consoleOutput.some((l) => l.includes('invalid'))).toBe(true); + }); + }); + + describe('runApiKeyDelete', () => { + it('deletes API key by ID', async () => { + mockSdk.apiKeys.deleteApiKey.mockResolvedValue(undefined); + await runApiKeyDelete('key_123', 'sk_test'); + expect(mockSdk.apiKeys.deleteApiKey).toHaveBeenCalledWith('key_123'); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('list outputs { data, listMetadata }', async () => { + mockSdk.organizations.listOrganizationApiKeys.mockResolvedValue({ + data: [mockApiKey], + listMetadata: { before: null, after: 'cursor_a' }, + }); + await runApiKeyList({ organizationId: 'org_456' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].id).toBe('key_123'); + expect(output.listMetadata.after).toBe('cursor_a'); + }); + + it('create includes key value in output', async () => { + mockSdk.organizations.createOrganizationApiKey.mockResolvedValue({ ...mockApiKey, value: 'sk_test_full_key' }); + await runApiKeyCreate({ organizationId: 'org_456', name: 'My Key' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.value).toBe('sk_test_full_key'); + }); + + it('validate outputs raw JSON', async () => { + mockSdk.apiKeys.validateApiKey.mockResolvedValue({ apiKey: mockApiKey }); + await runApiKeyValidate('sk_test_value', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.apiKey.id).toBe('key_123'); + }); + + it('delete outputs JSON success', async () => { + mockSdk.apiKeys.deleteApiKey.mockResolvedValue(undefined); + await runApiKeyDelete('key_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('key_123'); + }); + }); +}); diff --git a/src/commands/api-key-mgmt.ts b/src/commands/api-key-mgmt.ts new file mode 100644 index 0000000..a83f1a3 --- /dev/null +++ b/src/commands/api-key-mgmt.ts @@ -0,0 +1,122 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('ApiKey'); + +export interface ApiKeyListOptions { + organizationId: string; + limit?: number; + before?: string; + after?: string; + order?: string; +} + +export async function runApiKeyList(options: ApiKeyListOptions, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.organizations.listOrganizationApiKeys({ + organizationId: options.organizationId, + limit: options.limit, + before: options.before, + after: options.after, + order: options.order as 'asc' | 'desc' | undefined, + }); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No API keys found.'); + return; + } + + const rows = result.data.map((key) => [key.id, key.name, key.obfuscatedValue ?? chalk.dim('-'), key.createdAt]); + + console.log( + formatTable([{ header: 'ID' }, { header: 'Name' }, { header: 'Obfuscated Value' }, { header: 'Created' }], rows), + ); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} + +export interface ApiKeyCreateOptions { + organizationId: string; + name: string; + permissions?: string[]; +} + +export async function runApiKeyCreate(options: ApiKeyCreateOptions, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.organizations.createOrganizationApiKey({ + organizationId: options.organizationId, + name: options.name, + ...(options.permissions && { permissions: options.permissions }), + }); + + if (isJsonMode()) { + outputJson({ status: 'ok', message: 'Created API key', data: result }); + return; + } + + console.log(chalk.green('Created API key')); + console.log(JSON.stringify(result, null, 2)); + if (result.value) { + console.log(''); + console.log(chalk.yellow('API key value: ') + result.value); + console.log(chalk.yellow('Save this key now — it will not be shown again.')); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runApiKeyValidate(value: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.apiKeys.validateApiKey({ value }); + + if (isJsonMode()) { + outputJson(result); + return; + } + + if (result.apiKey) { + console.log(chalk.green('API key is valid')); + console.log(JSON.stringify(result.apiKey, null, 2)); + } else { + console.log(chalk.red('API key is invalid or not found.')); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runApiKeyDelete(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.apiKeys.deleteApiKey(id); + outputSuccess('Deleted API key', { id }); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/audit-log.spec.ts b/src/commands/audit-log.spec.ts new file mode 100644 index 0000000..c2c05aa --- /dev/null +++ b/src/commands/audit-log.spec.ts @@ -0,0 +1,402 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSdk = { + auditLogs: { + createEvent: vi.fn(), + createExport: vi.fn(), + getExport: vi.fn(), + createSchema: vi.fn(), + }, +}; + +const mockAuditLogs = { + listActions: vi.fn(), + getSchema: vi.fn(), + getRetention: vi.fn(), +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk, auditLogs: mockAuditLogs }), +})); + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})); + +const { readFile } = await import('node:fs/promises'); +const mockReadFile = vi.mocked(readFile); + +const { setOutputMode } = await import('../utils/output.js'); +const { + runAuditLogCreateEvent, + runAuditLogExport, + runAuditLogListActions, + runAuditLogGetSchema, + runAuditLogCreateSchema, + runAuditLogGetRetention, +} = await import('./audit-log.js'); + +describe('audit-log commands', () => { + let consoleOutput: string[]; + let stderrOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + stderrOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + stderrOutput.push(String(chunk)); + return true; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ── create-event ────────────────────────────────────────────────── + + describe('runAuditLogCreateEvent', () => { + it('creates event from flags', async () => { + mockSdk.auditLogs.createEvent.mockResolvedValue(undefined); + + await runAuditLogCreateEvent( + 'org_123', + { action: 'user.signed_in', actorType: 'user', actorId: 'user_01' }, + 'sk_test', + ); + + expect(mockSdk.auditLogs.createEvent).toHaveBeenCalledWith( + 'org_123', + expect.objectContaining({ + action: 'user.signed_in', + actor: expect.objectContaining({ id: 'user_01', type: 'user' }), + }), + ); + expect(consoleOutput.some((l) => l.includes('Created audit log event'))).toBe(true); + }); + + it('creates event from file', async () => { + const eventJson = { + action: 'user.signed_in', + occurredAt: '2025-01-15T10:00:00Z', + actor: { id: 'user_01', type: 'user' }, + targets: [], + context: { location: '127.0.0.1' }, + }; + mockReadFile.mockResolvedValue(JSON.stringify(eventJson)); + mockSdk.auditLogs.createEvent.mockResolvedValue(undefined); + + await runAuditLogCreateEvent('org_123', { file: 'event.json' }, 'sk_test'); + + expect(mockReadFile).toHaveBeenCalledWith('event.json', 'utf-8'); + expect(mockSdk.auditLogs.createEvent).toHaveBeenCalledWith('org_123', eventJson); + }); + + it('errors when required flags missing', async () => { + await expect(runAuditLogCreateEvent('org_123', { action: 'test' }, 'sk_test')).rejects.toThrow(); + }); + }); + + // ── export ──────────────────────────────────────────────────────── + + describe('runAuditLogExport', () => { + it('creates and polls export until ready', async () => { + mockSdk.auditLogs.createExport.mockResolvedValue({ + id: 'export_01', + state: 'pending', + url: null, + createdAt: '2025-01-15T10:00:00Z', + updatedAt: '2025-01-15T10:00:00Z', + }); + mockSdk.auditLogs.getExport + .mockResolvedValueOnce({ + id: 'export_01', + state: 'pending', + url: null, + createdAt: '2025-01-15T10:00:00Z', + updatedAt: '2025-01-15T10:00:01Z', + }) + .mockResolvedValueOnce({ + id: 'export_01', + state: 'ready', + url: 'https://exports.workos.com/export_01.csv', + createdAt: '2025-01-15T10:00:00Z', + updatedAt: '2025-01-15T10:00:02Z', + }); + + await runAuditLogExport( + { + organizationId: 'org_123', + rangeStart: '2025-01-01T00:00:00Z', + rangeEnd: '2025-02-01T00:00:00Z', + }, + 'sk_test', + ); + + expect(mockSdk.auditLogs.createExport).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org_123' }), + ); + expect(mockSdk.auditLogs.getExport).toHaveBeenCalledTimes(2); + expect(consoleOutput.some((l) => l.includes('Export ready'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('https://exports.workos.com/export_01.csv'))).toBe(true); + }); + + it('returns immediately when export is already ready', async () => { + mockSdk.auditLogs.createExport.mockResolvedValue({ + id: 'export_01', + state: 'ready', + url: 'https://exports.workos.com/export_01.csv', + createdAt: '2025-01-15T10:00:00Z', + updatedAt: '2025-01-15T10:00:00Z', + }); + + await runAuditLogExport( + { + organizationId: 'org_123', + rangeStart: '2025-01-01T00:00:00Z', + rangeEnd: '2025-02-01T00:00:00Z', + }, + 'sk_test', + ); + + expect(mockSdk.auditLogs.getExport).not.toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('Export ready'))).toBe(true); + }); + + it('handles export error state', async () => { + mockSdk.auditLogs.createExport.mockResolvedValue({ + id: 'export_01', + state: 'error', + url: null, + createdAt: '2025-01-15T10:00:00Z', + updatedAt: '2025-01-15T10:00:00Z', + }); + + await expect( + runAuditLogExport( + { + organizationId: 'org_123', + rangeStart: '2025-01-01T00:00:00Z', + rangeEnd: '2025-02-01T00:00:00Z', + }, + 'sk_test', + ), + ).rejects.toThrow(); + }); + + it('passes optional filters', async () => { + mockSdk.auditLogs.createExport.mockResolvedValue({ + id: 'export_01', + state: 'ready', + url: 'https://exports.workos.com/export_01.csv', + createdAt: '2025-01-15T10:00:00Z', + updatedAt: '2025-01-15T10:00:00Z', + }); + + await runAuditLogExport( + { + organizationId: 'org_123', + rangeStart: '2025-01-01T00:00:00Z', + rangeEnd: '2025-02-01T00:00:00Z', + actions: ['user.signed_in'], + actorNames: ['Alice'], + }, + 'sk_test', + ); + + expect(mockSdk.auditLogs.createExport).toHaveBeenCalledWith( + expect.objectContaining({ + actions: ['user.signed_in'], + actorNames: ['Alice'], + }), + ); + }); + }); + + // ── list-actions ────────────────────────────────────────────────── + + describe('runAuditLogListActions', () => { + it('lists actions in table format', async () => { + mockAuditLogs.listActions.mockResolvedValue({ + data: [{ action: 'user.signed_in' }, { action: 'user.signed_out' }], + list_metadata: { before: null, after: null }, + }); + + await runAuditLogListActions('sk_test'); + + expect(mockAuditLogs.listActions).toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('user.signed_in'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('user.signed_out'))).toBe(true); + }); + + it('handles empty results', async () => { + mockAuditLogs.listActions.mockResolvedValue({ + data: [], + list_metadata: { before: null, after: null }, + }); + + await runAuditLogListActions('sk_test'); + expect(consoleOutput.some((l) => l.includes('No audit log actions found'))).toBe(true); + }); + }); + + // ── get-schema ──────────────────────────────────────────────────── + + describe('runAuditLogGetSchema', () => { + it('prints schema for action', async () => { + const schema = { + version: 1, + targets: [{ type: 'user' }], + metadata: { ip: { type: 'string' } }, + }; + mockAuditLogs.getSchema.mockResolvedValue(schema); + + await runAuditLogGetSchema('user.signed_in', 'sk_test'); + + expect(mockAuditLogs.getSchema).toHaveBeenCalledWith('user.signed_in'); + expect(consoleOutput.some((l) => l.includes('user.signed_in'))).toBe(true); + }); + }); + + // ── create-schema ───────────────────────────────────────────────── + + describe('runAuditLogCreateSchema', () => { + it('creates schema from file', async () => { + const schemaJson = { targets: [{ type: 'user' }], metadata: { ip: { type: 'string' } } }; + mockReadFile.mockResolvedValue(JSON.stringify(schemaJson)); + mockSdk.auditLogs.createSchema.mockResolvedValue({ + object: 'audit_log_schema', + version: 1, + ...schemaJson, + createdAt: '2025-01-15T10:00:00Z', + }); + + await runAuditLogCreateSchema('user.signed_in', 'schema.json', 'sk_test'); + + expect(mockReadFile).toHaveBeenCalledWith('schema.json', 'utf-8'); + expect(mockSdk.auditLogs.createSchema).toHaveBeenCalledWith({ + action: 'user.signed_in', + ...schemaJson, + }); + expect(consoleOutput.some((l) => l.includes('Created audit log schema'))).toBe(true); + }); + }); + + // ── get-retention ───────────────────────────────────────────────── + + describe('runAuditLogGetRetention', () => { + it('prints retention period', async () => { + mockAuditLogs.getRetention.mockResolvedValue({ retention_period_in_days: 90 }); + + await runAuditLogGetRetention('org_123', 'sk_test'); + + expect(mockAuditLogs.getRetention).toHaveBeenCalledWith('org_123'); + expect(consoleOutput.some((l) => l.includes('90'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('days'))).toBe(true); + }); + }); + + // ── JSON output mode ────────────────────────────────────────────── + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runAuditLogCreateEvent outputs JSON success', async () => { + mockSdk.auditLogs.createEvent.mockResolvedValue(undefined); + + await runAuditLogCreateEvent( + 'org_123', + { action: 'user.signed_in', actorType: 'user', actorId: 'user_01' }, + 'sk_test', + ); + + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.organization_id).toBe('org_123'); + }); + + it('runAuditLogExport outputs JSON', async () => { + mockSdk.auditLogs.createExport.mockResolvedValue({ + id: 'export_01', + state: 'ready', + url: 'https://exports.workos.com/export_01.csv', + createdAt: '2025-01-15T10:00:00Z', + updatedAt: '2025-01-15T10:00:00Z', + }); + + await runAuditLogExport( + { + organizationId: 'org_123', + rangeStart: '2025-01-01T00:00:00Z', + rangeEnd: '2025-02-01T00:00:00Z', + }, + 'sk_test', + ); + + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('export_01'); + expect(output.state).toBe('ready'); + expect(output.url).toBe('https://exports.workos.com/export_01.csv'); + }); + + it('runAuditLogListActions outputs JSON', async () => { + const response = { + data: [{ action: 'user.signed_in' }], + list_metadata: { before: null, after: null }, + }; + mockAuditLogs.listActions.mockResolvedValue(response); + + await runAuditLogListActions('sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].action).toBe('user.signed_in'); + }); + + it('runAuditLogGetSchema outputs JSON', async () => { + const schema = { version: 1, targets: [{ type: 'user' }] }; + mockAuditLogs.getSchema.mockResolvedValue(schema); + + await runAuditLogGetSchema('user.signed_in', 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.version).toBe(1); + }); + + it('runAuditLogCreateSchema outputs JSON success', async () => { + const schemaJson = { targets: [{ type: 'user' }] }; + mockReadFile.mockResolvedValue(JSON.stringify(schemaJson)); + mockSdk.auditLogs.createSchema.mockResolvedValue({ + object: 'audit_log_schema', + version: 1, + ...schemaJson, + createdAt: '2025-01-15T10:00:00Z', + }); + + await runAuditLogCreateSchema('user.signed_in', 'schema.json', 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Created audit log schema'); + }); + + it('runAuditLogGetRetention outputs JSON', async () => { + mockAuditLogs.getRetention.mockResolvedValue({ retention_period_in_days: 90 }); + + await runAuditLogGetRetention('org_123', 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.retention_period_in_days).toBe(90); + }); + }); +}); diff --git a/src/commands/audit-log.ts b/src/commands/audit-log.ts new file mode 100644 index 0000000..bae4864 --- /dev/null +++ b/src/commands/audit-log.ts @@ -0,0 +1,224 @@ +import chalk from 'chalk'; +import { readFile } from 'node:fs/promises'; +import type { CreateAuditLogEventOptions } from '@workos-inc/node'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputJson, outputSuccess, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('AuditLog'); + +// ── create-event ────────────────────────────────────────────────────── + +export interface AuditLogCreateEventFlags { + action?: string; + actorType?: string; + actorId?: string; + actorName?: string; + targets?: string; + context?: string; + metadata?: string; + occurredAt?: string; + file?: string; +} + +export async function runAuditLogCreateEvent( + orgId: string, + flags: AuditLogCreateEventFlags, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + let event: CreateAuditLogEventOptions; + + if (flags.file) { + const raw = await readFile(flags.file, 'utf-8'); + event = JSON.parse(raw); + } else { + if (!flags.action || !flags.actorType || !flags.actorId) { + throw new Error('--action, --actor-type, and --actor-id are required (or use --file)'); + } + event = { + action: flags.action, + occurredAt: flags.occurredAt ? new Date(flags.occurredAt) : new Date(), + actor: { + id: flags.actorId, + type: flags.actorType, + ...(flags.actorName && { name: flags.actorName }), + }, + targets: flags.targets ? JSON.parse(flags.targets) : [], + context: flags.context ? JSON.parse(flags.context) : { location: '0.0.0.0' }, + ...(flags.metadata && { metadata: JSON.parse(flags.metadata) }), + }; + } + + await client.sdk.auditLogs.createEvent(orgId, event); + outputSuccess('Created audit log event', { organization_id: orgId, action: event.action }); + } catch (error) { + handleApiError(error); + } +} + +// ── export ──────────────────────────────────────────────────────────── + +export interface AuditLogExportOptions { + organizationId: string; + rangeStart: string; + rangeEnd: string; + actions?: string[]; + actorNames?: string[]; + actorIds?: string[]; + targets?: string[]; +} + +const POLL_MAX_ATTEMPTS = 60; +const POLL_INITIAL_DELAY_MS = 1000; +const POLL_MAX_DELAY_MS = 30000; + +export async function runAuditLogExport( + options: AuditLogExportOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const exportResult = await client.sdk.auditLogs.createExport({ + organizationId: options.organizationId, + rangeStart: new Date(options.rangeStart), + rangeEnd: new Date(options.rangeEnd), + ...(options.actions && { actions: options.actions }), + ...(options.actorNames && { actorNames: options.actorNames }), + ...(options.actorIds && { actorIds: options.actorIds }), + ...(options.targets && { targets: options.targets }), + }); + + let current = exportResult; + let delay = POLL_INITIAL_DELAY_MS; + + for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS && current.state === 'pending'; attempt++) { + if (!isJsonMode()) { + process.stderr.write('.'); + } + await new Promise((resolve) => setTimeout(resolve, delay)); + delay = Math.min(delay * 2, POLL_MAX_DELAY_MS); + current = await client.sdk.auditLogs.getExport(current.id); + } + + if (!isJsonMode() && current.state !== 'pending') { + process.stderr.write('\n'); + } + + if (current.state === 'error') { + throw new Error(`Export failed (id: ${current.id})`); + } + + if (current.state === 'pending') { + throw new Error(`Export timed out (id: ${current.id}). Check status later.`); + } + + if (isJsonMode()) { + outputJson(current); + return; + } + + console.log(chalk.green('Export ready')); + console.log(` ID: ${current.id}`); + if (current.url) { + console.log(` URL: ${current.url}`); + } + } catch (error) { + handleApiError(error); + } +} + +// ── list-actions ────────────────────────────────────────────────────── + +export async function runAuditLogListActions(apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.auditLogs.listActions(); + + if (isJsonMode()) { + outputJson(result); + return; + } + + if (result.data.length === 0) { + console.log('No audit log actions found.'); + return; + } + + const rows = result.data.map((item) => [item.action]); + console.log(formatTable([{ header: 'Action Name' }], rows)); + } catch (error) { + handleApiError(error); + } +} + +// ── get-schema ──────────────────────────────────────────────────────── + +export async function runAuditLogGetSchema(action: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.auditLogs.getSchema(action); + + if (isJsonMode()) { + outputJson(result); + return; + } + + console.log(chalk.bold(`Schema for ${action}`)); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + handleApiError(error); + } +} + +// ── create-schema ───────────────────────────────────────────────────── + +export async function runAuditLogCreateSchema( + action: string, + filePath: string, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const raw = await readFile(filePath, 'utf-8'); + const schema = JSON.parse(raw); + + const result = await client.sdk.auditLogs.createSchema({ + action, + ...schema, + }); + + outputSuccess('Created audit log schema', result); + } catch (error) { + handleApiError(error); + } +} + +// ── get-retention ───────────────────────────────────────────────────── + +export async function runAuditLogGetRetention(orgId: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.auditLogs.getRetention(orgId); + + if (isJsonMode()) { + outputJson(result); + return; + } + + console.log(`Retention period: ${chalk.bold(String(result.retention_period_in_days))} days`); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/config.spec.ts b/src/commands/config.spec.ts new file mode 100644 index 0000000..fa2360b --- /dev/null +++ b/src/commands/config.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockClient = { + sdk: {}, + redirectUris: { add: vi.fn() }, + corsOrigins: { add: vi.fn() }, + homepageUrl: { set: vi.fn() }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => mockClient, +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { runConfigRedirectAdd, runConfigCorsAdd, runConfigHomepageUrlSet } = await import('./config.js'); + +describe('config commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runConfigRedirectAdd', () => { + it('adds redirect URI', async () => { + mockClient.redirectUris.add.mockResolvedValue({ success: true, alreadyExists: false }); + await runConfigRedirectAdd('http://localhost:3000/callback', 'sk_test'); + expect(mockClient.redirectUris.add).toHaveBeenCalledWith('http://localhost:3000/callback'); + expect(consoleOutput.some((l) => l.includes('Added redirect URI'))).toBe(true); + }); + + it('handles already exists gracefully', async () => { + mockClient.redirectUris.add.mockResolvedValue({ success: true, alreadyExists: true }); + await runConfigRedirectAdd('http://localhost:3000/callback', 'sk_test'); + expect(consoleOutput.some((l) => l.includes('already exists'))).toBe(true); + }); + }); + + describe('runConfigCorsAdd', () => { + it('adds CORS origin', async () => { + mockClient.corsOrigins.add.mockResolvedValue({ success: true, alreadyExists: false }); + await runConfigCorsAdd('http://localhost:3000', 'sk_test'); + expect(mockClient.corsOrigins.add).toHaveBeenCalledWith('http://localhost:3000'); + expect(consoleOutput.some((l) => l.includes('Added CORS origin'))).toBe(true); + }); + + it('handles already exists gracefully', async () => { + mockClient.corsOrigins.add.mockResolvedValue({ success: true, alreadyExists: true }); + await runConfigCorsAdd('http://localhost:3000', 'sk_test'); + expect(consoleOutput.some((l) => l.includes('already exists'))).toBe(true); + }); + }); + + describe('runConfigHomepageUrlSet', () => { + it('sets homepage URL', async () => { + mockClient.homepageUrl.set.mockResolvedValue(undefined); + await runConfigHomepageUrlSet('http://localhost:3000', 'sk_test'); + expect(mockClient.homepageUrl.set).toHaveBeenCalledWith('http://localhost:3000'); + expect(consoleOutput.some((l) => l.includes('Set homepage URL'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('runConfigRedirectAdd outputs JSON success', async () => { + mockClient.redirectUris.add.mockResolvedValue({ success: true, alreadyExists: false }); + await runConfigRedirectAdd('http://localhost:3000/callback', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.uri).toBe('http://localhost:3000/callback'); + }); + + it('runConfigRedirectAdd outputs JSON for already exists', async () => { + mockClient.redirectUris.add.mockResolvedValue({ success: true, alreadyExists: true }); + await runConfigRedirectAdd('http://localhost:3000/callback', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.alreadyExists).toBe(true); + }); + + it('runConfigCorsAdd outputs JSON success', async () => { + mockClient.corsOrigins.add.mockResolvedValue({ success: true, alreadyExists: false }); + await runConfigCorsAdd('http://localhost:3000', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.origin).toBe('http://localhost:3000'); + }); + + it('runConfigHomepageUrlSet outputs JSON success', async () => { + mockClient.homepageUrl.set.mockResolvedValue(undefined); + await runConfigHomepageUrlSet('http://localhost:3000', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.url).toBe('http://localhost:3000'); + }); + }); +}); diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..6675b68 --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,59 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { outputSuccess, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Config'); + +export async function runConfigRedirectAdd(uri: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.redirectUris.add(uri); + + if (result.alreadyExists) { + if (isJsonMode()) { + outputSuccess('Redirect URI already exists', { uri, alreadyExists: true }); + } else { + console.log(chalk.yellow('Redirect URI already exists (no change)')); + } + return; + } + + outputSuccess('Added redirect URI', { uri }); + } catch (error) { + handleApiError(error); + } +} + +export async function runConfigCorsAdd(origin: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.corsOrigins.add(origin); + + if (result.alreadyExists) { + if (isJsonMode()) { + outputSuccess('CORS origin already exists', { origin, alreadyExists: true }); + } else { + console.log(chalk.yellow('CORS origin already exists (no change)')); + } + return; + } + + outputSuccess('Added CORS origin', { origin }); + } catch (error) { + handleApiError(error); + } +} + +export async function runConfigHomepageUrlSet(url: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.homepageUrl.set(url); + outputSuccess('Set homepage URL', { url }); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/connection.spec.ts b/src/commands/connection.spec.ts new file mode 100644 index 0000000..f76a725 --- /dev/null +++ b/src/commands/connection.spec.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock the unified client +const mockSdk = { + sso: { + listConnections: vi.fn(), + getConnection: vi.fn(), + deleteConnection: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +// Mock clack for confirmation prompts +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); + +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + }, +})); + +// Mock environment detection +vi.mock('../utils/environment.js', () => ({ + isNonInteractiveEnvironment: vi.fn(() => false), +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { isNonInteractiveEnvironment } = await import('../utils/environment.js'); + +const { runConnectionList, runConnectionGet, runConnectionDelete } = await import('./connection.js'); + +const mockConnection = { + id: 'conn_01ABC', + name: 'Okta SSO', + type: 'OktaSAML', + organizationId: 'org_123', + state: 'active', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + domains: [], +}; + +describe('connection commands', () => { + let consoleOutput: string[]; + let stderrOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + stderrOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + stderrOutput.push(args.map(String).join(' ')); + }); + mockConfirm.mockResolvedValue(true); + mockIsCancel.mockReturnValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setOutputMode('human'); + }); + + describe('runConnectionList', () => { + it('lists connections in table format', async () => { + mockSdk.sso.listConnections.mockResolvedValue({ + data: [mockConnection], + listMetadata: { before: null, after: null }, + }); + await runConnectionList({}, 'sk_test'); + expect(mockSdk.sso.listConnections).toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('Okta SSO'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('conn_01ABC'))).toBe(true); + }); + + it('passes filter params', async () => { + mockSdk.sso.listConnections.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runConnectionList({ organizationId: 'org_123', connectionType: 'OktaSAML', limit: 5 }, 'sk_test'); + expect(mockSdk.sso.listConnections).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org_123', connectionType: 'OktaSAML', limit: 5 }), + ); + }); + + it('handles empty results', async () => { + mockSdk.sso.listConnections.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runConnectionList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No connections found'))).toBe(true); + }); + + it('shows pagination cursors', async () => { + mockSdk.sso.listConnections.mockResolvedValue({ + data: [mockConnection], + listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + }); + await runConnectionList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + }); + }); + + describe('runConnectionGet', () => { + it('fetches and prints connection', async () => { + mockSdk.sso.getConnection.mockResolvedValue(mockConnection); + await runConnectionGet('conn_01ABC', 'sk_test'); + expect(mockSdk.sso.getConnection).toHaveBeenCalledWith('conn_01ABC'); + expect(consoleOutput.some((l) => l.includes('conn_01ABC'))).toBe(true); + }); + }); + + describe('runConnectionDelete', () => { + it('deletes after confirmation', async () => { + mockConfirm.mockResolvedValue(true); + mockSdk.sso.deleteConnection.mockResolvedValue(undefined); + await runConnectionDelete('conn_01ABC', {}, 'sk_test'); + expect(mockConfirm).toHaveBeenCalled(); + expect(mockSdk.sso.deleteConnection).toHaveBeenCalledWith('conn_01ABC'); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + }); + + it('skips confirmation with --force', async () => { + mockSdk.sso.deleteConnection.mockResolvedValue(undefined); + await runConnectionDelete('conn_01ABC', { force: true }, 'sk_test'); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockSdk.sso.deleteConnection).toHaveBeenCalledWith('conn_01ABC'); + }); + + it('cancels on declined confirmation', async () => { + mockConfirm.mockResolvedValue(false); + await runConnectionDelete('conn_01ABC', {}, 'sk_test'); + expect(mockSdk.sso.deleteConnection).not.toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('cancelled'))).toBe(true); + }); + + it('cancels on clack cancel', async () => { + mockConfirm.mockResolvedValue(Symbol('cancel')); + mockIsCancel.mockReturnValue(true); + await runConnectionDelete('conn_01ABC', {}, 'sk_test'); + expect(mockSdk.sso.deleteConnection).not.toHaveBeenCalled(); + }); + + it('requires --force in non-interactive mode', async () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + await expect(runConnectionDelete('conn_01ABC', {}, 'sk_test')).rejects.toThrow('process.exit(1)'); + expect(mockSdk.sso.deleteConnection).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + it('runConnectionList outputs JSON with data and listMetadata', async () => { + mockSdk.sso.listConnections.mockResolvedValue({ + data: [mockConnection], + listMetadata: { before: null, after: 'cursor_a' }, + }); + await runConnectionList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].id).toBe('conn_01ABC'); + expect(output.listMetadata.after).toBe('cursor_a'); + }); + + it('runConnectionList outputs empty data for no results', async () => { + mockSdk.sso.listConnections.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runConnectionList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + expect(output.listMetadata).toBeDefined(); + }); + + it('runConnectionGet outputs raw JSON', async () => { + mockSdk.sso.getConnection.mockResolvedValue(mockConnection); + await runConnectionGet('conn_01ABC', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('conn_01ABC'); + expect(output.name).toBe('Okta SSO'); + }); + + it('runConnectionDelete outputs JSON success', async () => { + mockSdk.sso.deleteConnection.mockResolvedValue(undefined); + await runConnectionDelete('conn_01ABC', { force: true }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('conn_01ABC'); + }); + }); +}); diff --git a/src/commands/connection.ts b/src/commands/connection.ts new file mode 100644 index 0000000..45081f2 --- /dev/null +++ b/src/commands/connection.ts @@ -0,0 +1,127 @@ +import chalk from 'chalk'; +import type { ConnectionType } from '@workos-inc/node'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode, exitWithError } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import clack from '../utils/clack.js'; + +const handleApiError = createApiErrorHandler('Connection'); + +export interface ConnectionListOptions { + organizationId?: string; + connectionType?: string; + limit?: number; + before?: string; + after?: string; + order?: string; +} + +export async function runConnectionList( + options: ConnectionListOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.sso.listConnections({ + ...(options.organizationId && { organizationId: options.organizationId }), + ...(options.connectionType && { connectionType: options.connectionType as ConnectionType }), + limit: options.limit, + before: options.before, + after: options.after, + order: options.order as 'asc' | 'desc' | undefined, + }); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No connections found.'); + return; + } + + const rows = result.data.map((conn) => [ + conn.id, + conn.name, + conn.type, + conn.organizationId || chalk.dim('-'), + conn.state, + conn.createdAt, + ]); + + console.log( + formatTable( + [ + { header: 'ID' }, + { header: 'Name' }, + { header: 'Type' }, + { header: 'Org ID' }, + { header: 'State' }, + { header: 'Created' }, + ], + rows, + ), + ); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runConnectionGet(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const connection = await client.sdk.sso.getConnection(id); + outputJson(connection); + } catch (error) { + handleApiError(error); + } +} + +export async function runConnectionDelete( + id: string, + options: { force?: boolean }, + apiKey: string, + baseUrl?: string, +): Promise { + if (!options.force) { + if (isNonInteractiveEnvironment()) { + exitWithError({ + code: 'confirmation_required', + message: 'Destructive operation requires --force flag in non-interactive mode.', + }); + } + + const confirmed = await clack.confirm({ + message: `Delete connection ${id}? This cannot be undone.`, + }); + + if (clack.isCancel(confirmed) || !confirmed) { + console.log('Delete cancelled.'); + return; + } + } + + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.sso.deleteConnection(id); + outputSuccess('Deleted connection', { id }); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/debug-sso.spec.ts b/src/commands/debug-sso.spec.ts new file mode 100644 index 0000000..6c4b102 --- /dev/null +++ b/src/commands/debug-sso.spec.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSdk = { + sso: { getConnection: vi.fn() }, + events: { listEvents: vi.fn() }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { runDebugSso } = await import('./debug-sso.js'); + +describe('debug-sso command', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + const activeConnection = { + id: 'conn_123', + name: 'Okta SSO', + type: 'OktaSAML', + state: 'active', + organizationId: 'org_123', + createdAt: '2024-01-01', + }; + + const inactiveConnection = { + ...activeConnection, + name: 'Broken SSO', + state: 'inactive', + }; + + it('displays connection details', async () => { + mockSdk.sso.getConnection.mockResolvedValue(activeConnection); + mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + + await runDebugSso('conn_123', 'sk_test'); + + expect(mockSdk.sso.getConnection).toHaveBeenCalledWith('conn_123'); + expect(consoleOutput.some((l) => l.includes('Okta SSO'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('OktaSAML'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('org_123'))).toBe(true); + }); + + it('reports no issues for active connection', async () => { + mockSdk.sso.getConnection.mockResolvedValue(activeConnection); + mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + + await runDebugSso('conn_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('No issues detected'))).toBe(true); + }); + + it('identifies inactive connection as an issue', async () => { + mockSdk.sso.getConnection.mockResolvedValue(inactiveConnection); + mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + + await runDebugSso('conn_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('not active'))).toBe(true); + }); + + it('shows recent auth events', async () => { + mockSdk.sso.getConnection.mockResolvedValue(activeConnection); + mockSdk.events.listEvents.mockResolvedValue({ + data: [ + { id: 'evt_1', event: 'authentication.sso_succeeded', createdAt: '2024-01-02' }, + { id: 'evt_2', event: 'authentication.email_verification_succeeded', createdAt: '2024-01-03' }, + ], + listMetadata: {}, + }); + + await runDebugSso('conn_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('sso_succeeded'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('email_verification_succeeded'))).toBe(true); + }); + + it('handles event listing failure gracefully', async () => { + mockSdk.sso.getConnection.mockResolvedValue(activeConnection); + mockSdk.events.listEvents.mockRejectedValue(new Error('Events not available')); + + await runDebugSso('conn_123', 'sk_test'); + + // Should still complete without crashing + expect(consoleOutput.some((l) => l.includes('Okta SSO'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('No recent'))).toBe(true); + }); + + it('filters events by organizationId when connection has one', async () => { + mockSdk.sso.getConnection.mockResolvedValue(activeConnection); + mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + + await runDebugSso('conn_123', 'sk_test'); + + expect(mockSdk.events.listEvents).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 'org_123' })); + }); + + it('does not filter by org when connection has no organizationId', async () => { + mockSdk.sso.getConnection.mockResolvedValue({ ...activeConnection, organizationId: null }); + mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + + await runDebugSso('conn_123', 'sk_test'); + + const callArgs = mockSdk.events.listEvents.mock.calls[0][0]; + expect(callArgs).not.toHaveProperty('organizationId'); + }); + + describe('JSON mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('outputs connection details as JSON', async () => { + mockSdk.sso.getConnection.mockResolvedValue(activeConnection); + mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + + await runDebugSso('conn_123', 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.connection.id).toBe('conn_123'); + expect(output.connection.name).toBe('Okta SSO'); + expect(output.connection.type).toBe('OktaSAML'); + expect(output.connection.state).toBe('active'); + expect(output.recentEvents).toEqual([]); + expect(output.issues).toEqual([]); + }); + + it('includes issues in JSON for inactive connection', async () => { + mockSdk.sso.getConnection.mockResolvedValue(inactiveConnection); + mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + + await runDebugSso('conn_123', 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.issues).toContain('Connection is inactive (not active)'); + }); + + it('includes events in JSON', async () => { + mockSdk.sso.getConnection.mockResolvedValue(activeConnection); + mockSdk.events.listEvents.mockResolvedValue({ + data: [{ id: 'evt_1', event: 'authentication.sso_succeeded', createdAt: '2024-01-02' }], + listMetadata: {}, + }); + + await runDebugSso('conn_123', 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.recentEvents).toHaveLength(1); + expect(output.recentEvents[0].event).toBe('authentication.sso_succeeded'); + }); + }); +}); diff --git a/src/commands/debug-sso.ts b/src/commands/debug-sso.ts new file mode 100644 index 0000000..3299824 --- /dev/null +++ b/src/commands/debug-sso.ts @@ -0,0 +1,84 @@ +import chalk from 'chalk'; +import type { EventName } from '@workos-inc/node'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Connection'); + +export async function runDebugSso(connectionId: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + // 1. Get connection details + const connection = await client.sdk.sso.getConnection(connectionId); + + const issues: string[] = []; + + // 2. Check for common issues + if (connection.state !== 'active') { + issues.push(`Connection is ${connection.state} (not active)`); + } + + // 3. List recent authentication events + let recentEvents: Array<{ id: string; event: string; createdAt: string }> = []; + try { + const events = await client.sdk.events.listEvents({ + events: [ + 'authentication.email_verification_succeeded', + 'authentication.magic_auth_succeeded', + 'authentication.sso_succeeded', + ] as EventName[], + ...(connection.organizationId && { organizationId: connection.organizationId }), + limit: 5, + }); + recentEvents = events.data.map((e) => ({ id: e.id, event: e.event, createdAt: e.createdAt })); + } catch { + // Events may not be available + } + + if (isJsonMode()) { + outputJson({ + connection: { + id: connection.id, + name: connection.name, + type: connection.type, + state: connection.state, + organizationId: connection.organizationId, + createdAt: connection.createdAt, + }, + recentEvents, + issues, + }); + return; + } + + // 4. Human-readable diagnosis + console.log(chalk.bold(`SSO Connection: ${connection.name}`)); + console.log(` ID: ${connection.id}`); + console.log(` Type: ${connection.type}`); + console.log(` State: ${connection.state === 'active' ? chalk.green('active') : chalk.yellow(connection.state)}`); + console.log(` Organization: ${connection.organizationId || chalk.dim('none')}`); + console.log(` Created: ${connection.createdAt}`); + + if (recentEvents.length > 0) { + console.log(chalk.bold('\nRecent auth events:')); + for (const event of recentEvents) { + console.log(` ${event.event} (${event.createdAt})`); + } + } else { + console.log(chalk.dim('\nNo recent authentication events found.')); + } + + if (issues.length > 0) { + console.log(chalk.bold('\nIssues found:')); + for (const issue of issues) { + console.log(chalk.yellow(` ⚠ ${issue}`)); + } + } else { + console.log(chalk.green('\nNo issues detected.')); + } + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/debug-sync.spec.ts b/src/commands/debug-sync.spec.ts new file mode 100644 index 0000000..67acd4c --- /dev/null +++ b/src/commands/debug-sync.spec.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSdk = { + directorySync: { + getDirectory: vi.fn(), + listUsers: vi.fn(), + listGroups: vi.fn(), + }, + events: { listEvents: vi.fn() }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { runDebugSync } = await import('./debug-sync.js'); + +describe('debug-sync command', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + const linkedDirectory = { + id: 'dir_123', + name: 'Okta SCIM', + type: 'okta scim v2.0', + state: 'linked', + organizationId: 'org_123', + createdAt: '2024-01-01', + }; + + const unlinkedDirectory = { + ...linkedDirectory, + name: 'Broken Dir', + state: 'unlinked', + organizationId: null, + }; + + function mockCountsAndEvents(opts?: { + users?: number; + hasMore?: boolean; + groups?: number; + events?: Array<{ id: string; event: string; createdAt: string }>; + }) { + const users = Array.from({ length: opts?.users ?? 0 }, (_, i) => ({ id: `u${i}` })); + const groups = Array.from({ length: opts?.groups ?? 0 }, (_, i) => ({ id: `g${i}` })); + mockSdk.directorySync.listUsers.mockResolvedValue({ + data: users, + listMetadata: { after: opts?.hasMore ? 'cursor' : null }, + }); + mockSdk.directorySync.listGroups.mockResolvedValue({ data: groups, listMetadata: { after: null } }); + mockSdk.events.listEvents.mockResolvedValue({ data: opts?.events ?? [], listMetadata: {} }); + } + + it('displays directory details', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(linkedDirectory); + mockCountsAndEvents({ + users: 1, + groups: 1, + events: [{ id: 'evt_1', event: 'dsync.user.created', createdAt: '2024-01-02' }], + }); + + await runDebugSync('dir_123', 'sk_test'); + + expect(mockSdk.directorySync.getDirectory).toHaveBeenCalledWith('dir_123'); + expect(consoleOutput.some((l) => l.includes('Okta SCIM'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('okta scim v2.0'))).toBe(true); + }); + + it('shows user and group counts', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(linkedDirectory); + mockCountsAndEvents({ + users: 1, + groups: 1, + events: [{ id: 'e', event: 'dsync.user.created', createdAt: '2024-01-01' }], + }); + + await runDebugSync('dir_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('Users: 1'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('Groups: 1'))).toBe(true); + }); + + it('shows 1+ when pagination indicates more results', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(linkedDirectory); + mockCountsAndEvents({ + users: 1, + hasMore: true, + events: [{ id: 'e', event: 'dsync.user.created', createdAt: '2024-01-01' }], + }); + + await runDebugSync('dir_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('Users: 1+'))).toBe(true); + }); + + it('reports no issues for linked directory with events', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(linkedDirectory); + mockCountsAndEvents({ events: [{ id: 'e', event: 'dsync.user.created', createdAt: '2024-01-01' }] }); + + await runDebugSync('dir_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('No issues detected'))).toBe(true); + }); + + it('identifies unlinked directory as an issue', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(unlinkedDirectory); + mockCountsAndEvents(); + + await runDebugSync('dir_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('not linked'))).toBe(true); + }); + + it('warns when no sync events found (stalled)', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(linkedDirectory); + mockCountsAndEvents({ events: [] }); + + await runDebugSync('dir_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('stalled'))).toBe(true); + }); + + it('shows recent sync events', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(linkedDirectory); + mockCountsAndEvents({ + events: [ + { id: 'evt_1', event: 'dsync.user.created', createdAt: '2024-01-02' }, + { id: 'evt_2', event: 'dsync.group.created', createdAt: '2024-01-03' }, + ], + }); + + await runDebugSync('dir_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('dsync.user.created'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('dsync.group.created'))).toBe(true); + }); + + it('handles user listing failure gracefully', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(linkedDirectory); + mockSdk.directorySync.listUsers.mockRejectedValue(new Error('Access denied')); + mockSdk.directorySync.listGroups.mockResolvedValue({ data: [], listMetadata: { after: null } }); + mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + + await runDebugSync('dir_123', 'sk_test'); + + // Should still complete + expect(consoleOutput.some((l) => l.includes('Okta SCIM'))).toBe(true); + }); + + it('handles event listing failure gracefully', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(linkedDirectory); + mockSdk.directorySync.listUsers.mockResolvedValue({ data: [], listMetadata: { after: null } }); + mockSdk.directorySync.listGroups.mockResolvedValue({ data: [], listMetadata: { after: null } }); + mockSdk.events.listEvents.mockRejectedValue(new Error('Events not available')); + + await runDebugSync('dir_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('Okta SCIM'))).toBe(true); + }); + + it('filters events by organizationId when directory has one', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(linkedDirectory); + mockCountsAndEvents(); + + await runDebugSync('dir_123', 'sk_test'); + + expect(mockSdk.events.listEvents).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 'org_123' })); + }); + + describe('JSON mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('outputs directory details as JSON', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(linkedDirectory); + mockCountsAndEvents({ + users: 1, + groups: 1, + events: [{ id: 'e', event: 'dsync.user.created', createdAt: '2024-01-01' }], + }); + + await runDebugSync('dir_123', 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.directory.id).toBe('dir_123'); + expect(output.directory.name).toBe('Okta SCIM'); + expect(output.userCount).toBe(1); + expect(output.groupCount).toBe(1); + expect(output.recentEvents).toHaveLength(1); + expect(output.issues).toEqual([]); + }); + + it('includes issues in JSON for unlinked directory', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(unlinkedDirectory); + mockCountsAndEvents(); + + await runDebugSync('dir_123', 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.issues).toEqual(expect.arrayContaining([expect.stringContaining('not linked')])); + }); + + it('reports 1+ user count as string in JSON', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(linkedDirectory); + mockCountsAndEvents({ + users: 1, + hasMore: true, + events: [{ id: 'e', event: 'dsync.user.created', createdAt: '2024-01-01' }], + }); + + await runDebugSync('dir_123', 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.userCount).toBe('1+'); + }); + }); +}); diff --git a/src/commands/debug-sync.ts b/src/commands/debug-sync.ts new file mode 100644 index 0000000..b1b4e1d --- /dev/null +++ b/src/commands/debug-sync.ts @@ -0,0 +1,109 @@ +import chalk from 'chalk'; +import type { EventName } from '@workos-inc/node'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Directory'); + +export async function runDebugSync(directoryId: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + // 1. Get directory details + const directory = await client.sdk.directorySync.getDirectory(directoryId); + + const issues: string[] = []; + + // 2. Check state + if (String(directory.state) !== 'linked') { + issues.push(`Directory is ${directory.state} (not linked)`); + } + + // 3. Count users and groups + let userCount = 0; + let groupCount = 0; + try { + const users = await client.sdk.directorySync.listUsers({ directory: directoryId, limit: 1 }); + userCount = users.data.length; + // If there's pagination, there are more + if (users.listMetadata.after) userCount = -1; // indicates "more than 1" + } catch { + // May not have access + } + + try { + const groups = await client.sdk.directorySync.listGroups({ directory: directoryId, limit: 1 }); + groupCount = groups.data.length; + if (groups.listMetadata.after) groupCount = -1; + } catch { + // May not have access + } + + // 4. List recent sync events + let recentEvents: Array<{ id: string; event: string; createdAt: string }> = []; + try { + const events = await client.sdk.events.listEvents({ + events: ['dsync.user.created', 'dsync.user.updated', 'dsync.group.created'] as EventName[], + ...(directory.organizationId && { organizationId: directory.organizationId }), + limit: 5, + }); + recentEvents = events.data.map((e) => ({ id: e.id, event: e.event, createdAt: e.createdAt })); + } catch { + // Events may not be available + } + + if (recentEvents.length === 0) { + issues.push('No recent sync events found — sync may be stalled'); + } + + if (isJsonMode()) { + outputJson({ + directory: { + id: directory.id, + name: directory.name, + type: directory.type, + state: directory.state, + organizationId: directory.organizationId, + createdAt: directory.createdAt, + }, + userCount: userCount === -1 ? '1+' : userCount, + groupCount: groupCount === -1 ? '1+' : groupCount, + recentEvents, + issues, + }); + return; + } + + // 5. Human-readable diagnosis + console.log(chalk.bold(`Directory Sync: ${directory.name}`)); + console.log(` ID: ${directory.id}`); + console.log(` Type: ${directory.type}`); + console.log( + ` State: ${String(directory.state) === 'linked' ? chalk.green('linked') : chalk.yellow(directory.state)}`, + ); + console.log(` Organization: ${directory.organizationId || chalk.dim('none')}`); + console.log(` Users: ${userCount === -1 ? '1+' : userCount}`); + console.log(` Groups: ${groupCount === -1 ? '1+' : groupCount}`); + + if (recentEvents.length > 0) { + console.log(chalk.bold('\nRecent sync events:')); + for (const event of recentEvents) { + console.log(` ${event.event} (${event.createdAt})`); + } + } else { + console.log(chalk.dim('\nNo recent sync events found.')); + } + + if (issues.length > 0) { + console.log(chalk.bold('\nIssues found:')); + for (const issue of issues) { + console.log(chalk.yellow(` ⚠ ${issue}`)); + } + } else { + console.log(chalk.green('\nNo issues detected.')); + } + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/directory.spec.ts b/src/commands/directory.spec.ts new file mode 100644 index 0000000..d09e7c6 --- /dev/null +++ b/src/commands/directory.spec.ts @@ -0,0 +1,344 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock the unified client +const mockSdk = { + directorySync: { + listDirectories: vi.fn(), + getDirectory: vi.fn(), + deleteDirectory: vi.fn(), + listUsers: vi.fn(), + listGroups: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +// Mock clack for confirmation prompts +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); + +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + }, +})); + +// Mock environment detection +vi.mock('../utils/environment.js', () => ({ + isNonInteractiveEnvironment: vi.fn(() => false), +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { isNonInteractiveEnvironment } = await import('../utils/environment.js'); + +const { runDirectoryList, runDirectoryGet, runDirectoryDelete, runDirectoryListUsers, runDirectoryListGroups } = + await import('./directory.js'); + +const mockDirectory = { + id: 'directory_01ABC', + name: 'Okta SCIM', + type: 'okta scim v2.0', + organizationId: 'org_123', + state: 'active', + domain: 'example.com', + externalKey: 'ext_key', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', +}; + +const mockDirectoryUser = { + id: 'directory_user_01ABC', + email: 'user@example.com', + firstName: 'Jane', + lastName: 'Doe', + state: 'active', + directoryId: 'directory_01ABC', + organizationId: 'org_123', + idpId: 'idp_123', + customAttributes: {}, + rawAttributes: {}, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', +}; + +const mockDirectoryGroup = { + id: 'directory_group_01ABC', + name: 'Engineering', + directoryId: 'directory_01ABC', + organizationId: 'org_123', + idpId: 'idp_123', + rawAttributes: {}, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', +}; + +describe('directory commands', () => { + let consoleOutput: string[]; + let stderrOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + stderrOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + stderrOutput.push(args.map(String).join(' ')); + }); + mockConfirm.mockResolvedValue(true); + mockIsCancel.mockReturnValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setOutputMode('human'); + }); + + describe('runDirectoryList', () => { + it('lists directories in table format', async () => { + mockSdk.directorySync.listDirectories.mockResolvedValue({ + data: [mockDirectory], + listMetadata: { before: null, after: null }, + }); + await runDirectoryList({}, 'sk_test'); + expect(mockSdk.directorySync.listDirectories).toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('Okta SCIM'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('directory_01ABC'))).toBe(true); + }); + + it('passes organization filter', async () => { + mockSdk.directorySync.listDirectories.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runDirectoryList({ organizationId: 'org_123' }, 'sk_test'); + expect(mockSdk.directorySync.listDirectories).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org_123' }), + ); + }); + + it('handles empty results', async () => { + mockSdk.directorySync.listDirectories.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runDirectoryList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No directories found'))).toBe(true); + }); + + it('shows pagination cursors', async () => { + mockSdk.directorySync.listDirectories.mockResolvedValue({ + data: [mockDirectory], + listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + }); + await runDirectoryList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + }); + }); + + describe('runDirectoryGet', () => { + it('fetches and prints directory', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(mockDirectory); + await runDirectoryGet('directory_01ABC', 'sk_test'); + expect(mockSdk.directorySync.getDirectory).toHaveBeenCalledWith('directory_01ABC'); + expect(consoleOutput.some((l) => l.includes('directory_01ABC'))).toBe(true); + }); + }); + + describe('runDirectoryDelete', () => { + it('deletes after confirmation', async () => { + mockConfirm.mockResolvedValue(true); + mockSdk.directorySync.deleteDirectory.mockResolvedValue(undefined); + await runDirectoryDelete('directory_01ABC', {}, 'sk_test'); + expect(mockConfirm).toHaveBeenCalled(); + expect(mockSdk.directorySync.deleteDirectory).toHaveBeenCalledWith('directory_01ABC'); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + }); + + it('skips confirmation with --force', async () => { + mockSdk.directorySync.deleteDirectory.mockResolvedValue(undefined); + await runDirectoryDelete('directory_01ABC', { force: true }, 'sk_test'); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockSdk.directorySync.deleteDirectory).toHaveBeenCalledWith('directory_01ABC'); + }); + + it('cancels on declined confirmation', async () => { + mockConfirm.mockResolvedValue(false); + await runDirectoryDelete('directory_01ABC', {}, 'sk_test'); + expect(mockSdk.directorySync.deleteDirectory).not.toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('cancelled'))).toBe(true); + }); + + it('cancels on clack cancel', async () => { + mockConfirm.mockResolvedValue(Symbol('cancel')); + mockIsCancel.mockReturnValue(true); + await runDirectoryDelete('directory_01ABC', {}, 'sk_test'); + expect(mockSdk.directorySync.deleteDirectory).not.toHaveBeenCalled(); + }); + + it('requires --force in non-interactive mode', async () => { + vi.mocked(isNonInteractiveEnvironment).mockReturnValue(true); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + await expect(runDirectoryDelete('directory_01ABC', {}, 'sk_test')).rejects.toThrow('process.exit(1)'); + expect(mockSdk.directorySync.deleteDirectory).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('runDirectoryListUsers', () => { + it('lists users in table format', async () => { + mockSdk.directorySync.listUsers.mockResolvedValue({ + data: [mockDirectoryUser], + listMetadata: { before: null, after: null }, + }); + await runDirectoryListUsers({ directory: 'directory_01ABC' }, 'sk_test'); + expect(mockSdk.directorySync.listUsers).toHaveBeenCalledWith( + expect.objectContaining({ directory: 'directory_01ABC' }), + ); + expect(consoleOutput.some((l) => l.includes('user@example.com'))).toBe(true); + }); + + it('passes group filter', async () => { + mockSdk.directorySync.listUsers.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runDirectoryListUsers({ group: 'directory_group_01ABC' }, 'sk_test'); + expect(mockSdk.directorySync.listUsers).toHaveBeenCalledWith( + expect.objectContaining({ group: 'directory_group_01ABC' }), + ); + }); + + it('requires --directory or --group', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + await expect(runDirectoryListUsers({}, 'sk_test')).rejects.toThrow('process.exit(1)'); + expect(mockSdk.directorySync.listUsers).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('handles empty results', async () => { + mockSdk.directorySync.listUsers.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runDirectoryListUsers({ directory: 'directory_01ABC' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No directory users found'))).toBe(true); + }); + + it('shows pagination cursors', async () => { + mockSdk.directorySync.listUsers.mockResolvedValue({ + data: [mockDirectoryUser], + listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + }); + await runDirectoryListUsers({ directory: 'directory_01ABC' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); + }); + }); + + describe('runDirectoryListGroups', () => { + it('lists groups in table format', async () => { + mockSdk.directorySync.listGroups.mockResolvedValue({ + data: [mockDirectoryGroup], + listMetadata: { before: null, after: null }, + }); + await runDirectoryListGroups({ directory: 'directory_01ABC' }, 'sk_test'); + expect(mockSdk.directorySync.listGroups).toHaveBeenCalledWith( + expect.objectContaining({ directory: 'directory_01ABC' }), + ); + expect(consoleOutput.some((l) => l.includes('Engineering'))).toBe(true); + }); + + it('handles empty results', async () => { + mockSdk.directorySync.listGroups.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runDirectoryListGroups({ directory: 'directory_01ABC' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No directory groups found'))).toBe(true); + }); + + it('shows pagination cursors', async () => { + mockSdk.directorySync.listGroups.mockResolvedValue({ + data: [mockDirectoryGroup], + listMetadata: { before: null, after: 'cursor_a' }, + }); + await runDirectoryListGroups({ directory: 'directory_01ABC' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + it('runDirectoryList outputs JSON with data and listMetadata', async () => { + mockSdk.directorySync.listDirectories.mockResolvedValue({ + data: [mockDirectory], + listMetadata: { before: null, after: 'cursor_a' }, + }); + await runDirectoryList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].id).toBe('directory_01ABC'); + expect(output.listMetadata.after).toBe('cursor_a'); + }); + + it('runDirectoryList outputs empty data for no results', async () => { + mockSdk.directorySync.listDirectories.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runDirectoryList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + }); + + it('runDirectoryGet outputs raw JSON', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue(mockDirectory); + await runDirectoryGet('directory_01ABC', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('directory_01ABC'); + expect(output.name).toBe('Okta SCIM'); + }); + + it('runDirectoryDelete outputs JSON success', async () => { + mockSdk.directorySync.deleteDirectory.mockResolvedValue(undefined); + await runDirectoryDelete('directory_01ABC', { force: true }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('directory_01ABC'); + }); + + it('runDirectoryListUsers outputs JSON', async () => { + mockSdk.directorySync.listUsers.mockResolvedValue({ + data: [mockDirectoryUser], + listMetadata: { before: null, after: null }, + }); + await runDirectoryListUsers({ directory: 'directory_01ABC' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].email).toBe('user@example.com'); + }); + + it('runDirectoryListGroups outputs JSON', async () => { + mockSdk.directorySync.listGroups.mockResolvedValue({ + data: [mockDirectoryGroup], + listMetadata: { before: null, after: null }, + }); + await runDirectoryListGroups({ directory: 'directory_01ABC' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].name).toBe('Engineering'); + }); + }); +}); diff --git a/src/commands/directory.ts b/src/commands/directory.ts new file mode 100644 index 0000000..095304e --- /dev/null +++ b/src/commands/directory.ts @@ -0,0 +1,238 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode, exitWithError } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; +import { isNonInteractiveEnvironment } from '../utils/environment.js'; +import clack from '../utils/clack.js'; + +const handleApiError = createApiErrorHandler('Directory'); + +export interface DirectoryListOptions { + organizationId?: string; + limit?: number; + before?: string; + after?: string; + order?: string; +} + +export async function runDirectoryList(options: DirectoryListOptions, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.directorySync.listDirectories({ + ...(options.organizationId && { organizationId: options.organizationId }), + limit: options.limit, + before: options.before, + after: options.after, + order: options.order as 'asc' | 'desc' | undefined, + }); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No directories found.'); + return; + } + + const rows = result.data.map((dir) => [ + dir.id, + dir.name, + dir.type, + dir.organizationId || chalk.dim('-'), + dir.state, + dir.createdAt, + ]); + + console.log( + formatTable( + [ + { header: 'ID' }, + { header: 'Name' }, + { header: 'Type' }, + { header: 'Org ID' }, + { header: 'State' }, + { header: 'Created' }, + ], + rows, + ), + ); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runDirectoryGet(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const directory = await client.sdk.directorySync.getDirectory(id); + outputJson(directory); + } catch (error) { + handleApiError(error); + } +} + +export async function runDirectoryDelete( + id: string, + options: { force?: boolean }, + apiKey: string, + baseUrl?: string, +): Promise { + if (!options.force) { + if (isNonInteractiveEnvironment()) { + exitWithError({ + code: 'confirmation_required', + message: 'Destructive operation requires --force flag in non-interactive mode.', + }); + } + + const confirmed = await clack.confirm({ + message: `Delete directory ${id}? This cannot be undone.`, + }); + + if (clack.isCancel(confirmed) || !confirmed) { + console.log('Delete cancelled.'); + return; + } + } + + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.directorySync.deleteDirectory(id); + outputSuccess('Deleted directory', { id }); + } catch (error) { + handleApiError(error); + } +} + +export interface DirectoryListUsersOptions { + directory?: string; + group?: string; + limit?: number; + before?: string; + after?: string; +} + +export async function runDirectoryListUsers( + options: DirectoryListUsersOptions, + apiKey: string, + baseUrl?: string, +): Promise { + if (!options.directory && !options.group) { + exitWithError({ + code: 'missing_args', + message: 'Either --directory or --group is required.', + }); + } + + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.directorySync.listUsers({ + ...(options.directory && { directory: options.directory }), + ...(options.group && { group: options.group }), + limit: options.limit, + before: options.before, + after: options.after, + }); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No directory users found.'); + return; + } + + const rows = result.data.map((user) => [ + user.id, + user.email || chalk.dim('-'), + user.firstName || chalk.dim('-'), + user.lastName || chalk.dim('-'), + user.state, + ]); + + console.log( + formatTable( + [{ header: 'ID' }, { header: 'Email' }, { header: 'First Name' }, { header: 'Last Name' }, { header: 'State' }], + rows, + ), + ); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} + +export interface DirectoryListGroupsOptions { + directory: string; + limit?: number; + before?: string; + after?: string; +} + +export async function runDirectoryListGroups( + options: DirectoryListGroupsOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.directorySync.listGroups({ + directory: options.directory, + limit: options.limit, + before: options.before, + after: options.after, + }); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No directory groups found.'); + return; + } + + const rows = result.data.map((group) => [group.id, group.name, group.createdAt]); + + console.log(formatTable([{ header: 'ID' }, { header: 'Name' }, { header: 'Created' }], rows)); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/event.spec.ts b/src/commands/event.spec.ts new file mode 100644 index 0000000..4a32843 --- /dev/null +++ b/src/commands/event.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSdk = { + events: { + listEvents: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { runEventList } = await import('./event.js'); + +describe('event commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runEventList', () => { + it('lists events in table format', async () => { + mockSdk.events.listEvents.mockResolvedValue({ + data: [ + { id: 'evt_01', event: 'dsync.user.created', createdAt: '2025-01-15T10:00:00Z' }, + { id: 'evt_02', event: 'connection.activated', createdAt: '2025-01-15T11:00:00Z' }, + ], + listMetadata: { before: null, after: null }, + }); + + await runEventList({ events: ['dsync.user.created', 'connection.activated'] }, 'sk_test'); + + expect(mockSdk.events.listEvents).toHaveBeenCalledWith( + expect.objectContaining({ events: ['dsync.user.created', 'connection.activated'] }), + ); + expect(consoleOutput.some((l) => l.includes('evt_01'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('dsync.user.created'))).toBe(true); + }); + + it('passes optional filters', async () => { + mockSdk.events.listEvents.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + + await runEventList( + { + events: ['dsync.user.created'], + after: 'cursor_a', + organizationId: 'org_123', + rangeStart: '2025-01-01', + rangeEnd: '2025-02-01', + limit: 10, + }, + 'sk_test', + ); + + expect(mockSdk.events.listEvents).toHaveBeenCalledWith( + expect.objectContaining({ + events: ['dsync.user.created'], + after: 'cursor_a', + organizationId: 'org_123', + rangeStart: '2025-01-01', + rangeEnd: '2025-02-01', + limit: 10, + }), + ); + }); + + it('handles empty results', async () => { + mockSdk.events.listEvents.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + + await runEventList({ events: ['dsync.user.created'] }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No events found'))).toBe(true); + }); + + it('shows pagination cursors', async () => { + mockSdk.events.listEvents.mockResolvedValue({ + data: [{ id: 'evt_01', event: 'dsync.user.created', createdAt: '2025-01-15T10:00:00Z' }], + listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + }); + + await runEventList({ events: ['dsync.user.created'] }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + }); + + it('handles API errors', async () => { + mockSdk.events.listEvents.mockRejectedValue(new Error('Bad request')); + + await expect(runEventList({ events: ['dsync.user.created'] }, 'sk_test')).rejects.toThrow(); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('outputs JSON with data and listMetadata', async () => { + mockSdk.events.listEvents.mockResolvedValue({ + data: [{ id: 'evt_01', event: 'dsync.user.created', createdAt: '2025-01-15T10:00:00Z' }], + listMetadata: { before: null, after: 'cursor_a' }, + }); + + await runEventList({ events: ['dsync.user.created'] }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].id).toBe('evt_01'); + expect(output.listMetadata.after).toBe('cursor_a'); + }); + + it('outputs empty data array for no results', async () => { + mockSdk.events.listEvents.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + + await runEventList({ events: ['dsync.user.created'] }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + expect(output.listMetadata).toBeDefined(); + }); + }); +}); diff --git a/src/commands/event.ts b/src/commands/event.ts new file mode 100644 index 0000000..b54c45e --- /dev/null +++ b/src/commands/event.ts @@ -0,0 +1,57 @@ +import chalk from 'chalk'; +import type { EventName } from '@workos-inc/node'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Event'); + +export interface EventListOptions { + events: string[]; + after?: string; + organizationId?: string; + rangeStart?: string; + rangeEnd?: string; + limit?: number; +} + +export async function runEventList(options: EventListOptions, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.events.listEvents({ + events: options.events as EventName[], + ...(options.after && { after: options.after }), + ...(options.organizationId && { organizationId: options.organizationId }), + ...(options.rangeStart && { rangeStart: options.rangeStart }), + ...(options.rangeEnd && { rangeEnd: options.rangeEnd }), + ...(options.limit && { limit: options.limit }), + }); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No events found.'); + return; + } + + const rows = result.data.map((event) => [event.id, event.event, event.createdAt]); + + console.log(formatTable([{ header: 'ID' }, { header: 'Event Type' }, { header: 'Created At' }], rows)); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/feature-flag.spec.ts b/src/commands/feature-flag.spec.ts new file mode 100644 index 0000000..2912bf6 --- /dev/null +++ b/src/commands/feature-flag.spec.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSdk = { + featureFlags: { + listFeatureFlags: vi.fn(), + getFeatureFlag: vi.fn(), + enableFeatureFlag: vi.fn(), + disableFeatureFlag: vi.fn(), + addFlagTarget: vi.fn(), + removeFlagTarget: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { + runFeatureFlagList, + runFeatureFlagGet, + runFeatureFlagEnable, + runFeatureFlagDisable, + runFeatureFlagAddTarget, + runFeatureFlagRemoveTarget, +} = await import('./feature-flag.js'); + +const mockFlag = { + id: 'ff_123', + slug: 'coffee', + name: 'Coffee Feature', + description: 'Enables coffee', + enabled: true, + defaultValue: false, + tags: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +describe('feature-flag commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runFeatureFlagList', () => { + it('lists flags in table', async () => { + mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ + data: [mockFlag], + listMetadata: { before: null, after: null }, + }); + await runFeatureFlagList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('coffee'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('Coffee Feature'))).toBe(true); + }); + + it('passes pagination params', async () => { + mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runFeatureFlagList({ limit: 5, order: 'desc' }, 'sk_test'); + expect(mockSdk.featureFlags.listFeatureFlags).toHaveBeenCalledWith( + expect.objectContaining({ limit: 5, order: 'desc' }), + ); + }); + + it('handles empty results', async () => { + mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runFeatureFlagList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No feature flags found'))).toBe(true); + }); + + it('shows pagination cursors', async () => { + mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ + data: [mockFlag], + listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + }); + await runFeatureFlagList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + }); + }); + + describe('runFeatureFlagGet', () => { + it('fetches flag by slug', async () => { + mockSdk.featureFlags.getFeatureFlag.mockResolvedValue(mockFlag); + await runFeatureFlagGet('coffee', 'sk_test'); + expect(mockSdk.featureFlags.getFeatureFlag).toHaveBeenCalledWith('coffee'); + expect(consoleOutput.some((l) => l.includes('coffee'))).toBe(true); + }); + }); + + describe('runFeatureFlagEnable', () => { + it('enables flag', async () => { + mockSdk.featureFlags.enableFeatureFlag.mockResolvedValue({ ...mockFlag, enabled: true }); + await runFeatureFlagEnable('coffee', 'sk_test'); + expect(mockSdk.featureFlags.enableFeatureFlag).toHaveBeenCalledWith('coffee'); + expect(consoleOutput.some((l) => l.includes('Enabled feature flag'))).toBe(true); + }); + }); + + describe('runFeatureFlagDisable', () => { + it('disables flag', async () => { + mockSdk.featureFlags.disableFeatureFlag.mockResolvedValue({ ...mockFlag, enabled: false }); + await runFeatureFlagDisable('coffee', 'sk_test'); + expect(mockSdk.featureFlags.disableFeatureFlag).toHaveBeenCalledWith('coffee'); + expect(consoleOutput.some((l) => l.includes('Disabled feature flag'))).toBe(true); + }); + }); + + describe('runFeatureFlagAddTarget', () => { + it('adds target with slug and targetId', async () => { + mockSdk.featureFlags.addFlagTarget.mockResolvedValue(undefined); + await runFeatureFlagAddTarget('coffee', 'user_123', 'sk_test'); + expect(mockSdk.featureFlags.addFlagTarget).toHaveBeenCalledWith({ slug: 'coffee', targetId: 'user_123' }); + expect(consoleOutput.some((l) => l.includes('Added target'))).toBe(true); + }); + }); + + describe('runFeatureFlagRemoveTarget', () => { + it('removes target with slug and targetId', async () => { + mockSdk.featureFlags.removeFlagTarget.mockResolvedValue(undefined); + await runFeatureFlagRemoveTarget('coffee', 'user_123', 'sk_test'); + expect(mockSdk.featureFlags.removeFlagTarget).toHaveBeenCalledWith({ slug: 'coffee', targetId: 'user_123' }); + expect(consoleOutput.some((l) => l.includes('Removed target'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('list outputs { data, listMetadata }', async () => { + mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ + data: [mockFlag], + listMetadata: { before: null, after: 'cursor_a' }, + }); + await runFeatureFlagList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].slug).toBe('coffee'); + expect(output.listMetadata.after).toBe('cursor_a'); + }); + + it('list outputs empty data array for no results', async () => { + mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runFeatureFlagList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + }); + + it('get outputs raw JSON', async () => { + mockSdk.featureFlags.getFeatureFlag.mockResolvedValue(mockFlag); + await runFeatureFlagGet('coffee', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.slug).toBe('coffee'); + expect(output).not.toHaveProperty('status'); + }); + + it('enable outputs JSON success', async () => { + mockSdk.featureFlags.enableFeatureFlag.mockResolvedValue({ ...mockFlag, enabled: true }); + await runFeatureFlagEnable('coffee', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Enabled feature flag'); + }); + }); +}); diff --git a/src/commands/feature-flag.ts b/src/commands/feature-flag.ts new file mode 100644 index 0000000..06e4efc --- /dev/null +++ b/src/commands/feature-flag.ts @@ -0,0 +1,128 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('FeatureFlag'); + +export interface FeatureFlagListOptions { + limit?: number; + before?: string; + after?: string; + order?: string; +} + +export async function runFeatureFlagList( + options: FeatureFlagListOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.featureFlags.listFeatureFlags({ + limit: options.limit, + before: options.before, + after: options.after, + order: options.order as 'asc' | 'desc' | undefined, + }); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No feature flags found.'); + return; + } + + const rows = result.data.map((flag) => [ + flag.slug, + flag.name ?? chalk.dim('-'), + flag.enabled ? chalk.green('Yes') : chalk.red('No'), + flag.description ?? chalk.dim('-'), + ]); + + console.log( + formatTable([{ header: 'Slug' }, { header: 'Name' }, { header: 'Enabled' }, { header: 'Description' }], rows), + ); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runFeatureFlagGet(slug: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.featureFlags.getFeatureFlag(slug); + outputJson(result); + } catch (error) { + handleApiError(error); + } +} + +export async function runFeatureFlagEnable(slug: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.featureFlags.enableFeatureFlag(slug); + outputSuccess('Enabled feature flag', result); + } catch (error) { + handleApiError(error); + } +} + +export async function runFeatureFlagDisable(slug: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.featureFlags.disableFeatureFlag(slug); + outputSuccess('Disabled feature flag', result); + } catch (error) { + handleApiError(error); + } +} + +export async function runFeatureFlagAddTarget( + slug: string, + targetId: string, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.featureFlags.addFlagTarget({ slug, targetId }); + outputSuccess('Added target to feature flag', { slug, targetId }); + } catch (error) { + handleApiError(error); + } +} + +export async function runFeatureFlagRemoveTarget( + slug: string, + targetId: string, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.featureFlags.removeFlagTarget({ slug, targetId }); + outputSuccess('Removed target from feature flag', { slug, targetId }); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/install.ts b/src/commands/install.ts index 35b769f..e1051ce 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -1,7 +1,6 @@ import { runInstaller } from '../run.js'; import type { InstallerArgs } from '../run.js'; import clack from '../utils/clack.js'; -import chalk from 'chalk'; import { exitWithError, isJsonMode } from '../utils/output.js'; import type { ArgumentsCamelCase } from 'yargs'; diff --git a/src/commands/invitation.spec.ts b/src/commands/invitation.spec.ts new file mode 100644 index 0000000..3060f6a --- /dev/null +++ b/src/commands/invitation.spec.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock the unified client +const mockSdk = { + userManagement: { + listInvitations: vi.fn(), + getInvitation: vi.fn(), + sendInvitation: vi.fn(), + revokeInvitation: vi.fn(), + resendInvitation: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { runInvitationList, runInvitationGet, runInvitationSend, runInvitationRevoke, runInvitationResend } = + await import('./invitation.js'); + +const mockInvitation = { + id: 'inv_123', + email: 'test@example.com', + state: 'pending', + organizationId: 'org_789', + expiresAt: '2024-02-01T00:00:00Z', + acceptedAt: null, + revokedAt: null, + inviterUserId: null, + acceptedUserId: null, + token: 'tok_abc', + acceptInvitationUrl: 'https://example.com/accept', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +describe('invitation commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runInvitationList', () => { + it('lists invitations', async () => { + mockSdk.userManagement.listInvitations.mockResolvedValue({ + data: [mockInvitation], + listMetadata: { before: null, after: null }, + }); + await runInvitationList({}, 'sk_test'); + expect(mockSdk.userManagement.listInvitations).toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('test@example.com'))).toBe(true); + }); + + it('passes org filter', async () => { + mockSdk.userManagement.listInvitations.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runInvitationList({ org: 'org_789' }, 'sk_test'); + expect(mockSdk.userManagement.listInvitations).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org_789' }), + ); + }); + + it('handles empty results', async () => { + mockSdk.userManagement.listInvitations.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runInvitationList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No invitations found'))).toBe(true); + }); + + it('shows pagination cursors', async () => { + mockSdk.userManagement.listInvitations.mockResolvedValue({ + data: [mockInvitation], + listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + }); + await runInvitationList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + }); + }); + + describe('runInvitationGet', () => { + it('fetches and prints invitation as JSON', async () => { + mockSdk.userManagement.getInvitation.mockResolvedValue(mockInvitation); + await runInvitationGet('inv_123', 'sk_test'); + expect(mockSdk.userManagement.getInvitation).toHaveBeenCalledWith('inv_123'); + expect(consoleOutput.some((l) => l.includes('inv_123'))).toBe(true); + }); + }); + + describe('runInvitationSend', () => { + it('sends invitation with email', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue(mockInvitation); + await runInvitationSend({ email: 'test@example.com' }, 'sk_test'); + expect(mockSdk.userManagement.sendInvitation).toHaveBeenCalledWith({ + email: 'test@example.com', + }); + }); + + it('sends invitation with all options', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue(mockInvitation); + await runInvitationSend( + { email: 'test@example.com', org: 'org_789', role: 'admin', expiresInDays: 7 }, + 'sk_test', + ); + expect(mockSdk.userManagement.sendInvitation).toHaveBeenCalledWith({ + email: 'test@example.com', + organizationId: 'org_789', + roleSlug: 'admin', + expiresInDays: 7, + }); + }); + + it('outputs sent message', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue(mockInvitation); + await runInvitationSend({ email: 'test@example.com' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Sent invitation'))).toBe(true); + }); + }); + + describe('runInvitationRevoke', () => { + it('revokes invitation', async () => { + const revoked = { ...mockInvitation, state: 'revoked' }; + mockSdk.userManagement.revokeInvitation.mockResolvedValue(revoked); + await runInvitationRevoke('inv_123', 'sk_test'); + expect(mockSdk.userManagement.revokeInvitation).toHaveBeenCalledWith('inv_123'); + expect(consoleOutput.some((l) => l.includes('Revoked invitation'))).toBe(true); + }); + }); + + describe('runInvitationResend', () => { + it('resends invitation', async () => { + mockSdk.userManagement.resendInvitation.mockResolvedValue(mockInvitation); + await runInvitationResend('inv_123', 'sk_test'); + expect(mockSdk.userManagement.resendInvitation).toHaveBeenCalledWith('inv_123'); + expect(consoleOutput.some((l) => l.includes('Resent invitation'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runInvitationGet outputs raw JSON', async () => { + mockSdk.userManagement.getInvitation.mockResolvedValue(mockInvitation); + await runInvitationGet('inv_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('inv_123'); + expect(output).not.toHaveProperty('status', 'ok'); + }); + + it('runInvitationList outputs JSON with data and listMetadata', async () => { + mockSdk.userManagement.listInvitations.mockResolvedValue({ + data: [mockInvitation], + listMetadata: { before: null, after: 'cursor_a' }, + }); + await runInvitationList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].id).toBe('inv_123'); + expect(output.listMetadata.after).toBe('cursor_a'); + }); + + it('runInvitationList outputs empty data array for no results', async () => { + mockSdk.userManagement.listInvitations.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runInvitationList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + expect(output.listMetadata).toBeDefined(); + }); + + it('runInvitationSend outputs JSON success', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue(mockInvitation); + await runInvitationSend({ email: 'test@example.com' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Sent invitation'); + expect(output.data.id).toBe('inv_123'); + }); + + it('runInvitationRevoke outputs JSON success', async () => { + const revoked = { ...mockInvitation, state: 'revoked' }; + mockSdk.userManagement.revokeInvitation.mockResolvedValue(revoked); + await runInvitationRevoke('inv_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Revoked invitation'); + }); + + it('runInvitationResend outputs JSON success', async () => { + mockSdk.userManagement.resendInvitation.mockResolvedValue(mockInvitation); + await runInvitationResend('inv_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Resent invitation'); + }); + }); +}); diff --git a/src/commands/invitation.ts b/src/commands/invitation.ts new file mode 100644 index 0000000..fd07fc7 --- /dev/null +++ b/src/commands/invitation.ts @@ -0,0 +1,131 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Invitation'); + +export interface InvitationListOptions { + org?: string; + email?: string; + limit?: number; + before?: string; + after?: string; + order?: string; +} + +export async function runInvitationList( + options: InvitationListOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.userManagement.listInvitations({ + ...(options.org && { organizationId: options.org }), + ...(options.email && { email: options.email }), + limit: options.limit, + before: options.before, + after: options.after, + order: options.order as 'asc' | 'desc' | undefined, + }); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No invitations found.'); + return; + } + + const rows = result.data.map((inv) => [ + inv.id, + inv.email, + inv.organizationId ?? chalk.dim('-'), + inv.state, + inv.expiresAt, + ]); + + console.log( + formatTable( + [{ header: 'ID' }, { header: 'Email' }, { header: 'Org ID' }, { header: 'State' }, { header: 'Expires At' }], + rows, + ), + ); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runInvitationGet(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const invitation = await client.sdk.userManagement.getInvitation(id); + outputJson(invitation); + } catch (error) { + handleApiError(error); + } +} + +export interface InvitationSendOptions { + email: string; + org?: string; + role?: string; + expiresInDays?: number; +} + +export async function runInvitationSend( + options: InvitationSendOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const invitation = await client.sdk.userManagement.sendInvitation({ + email: options.email, + ...(options.org && { organizationId: options.org }), + ...(options.role && { roleSlug: options.role }), + ...(options.expiresInDays !== undefined && { expiresInDays: options.expiresInDays }), + }); + outputSuccess('Sent invitation', invitation); + } catch (error) { + handleApiError(error); + } +} + +export async function runInvitationRevoke(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const invitation = await client.sdk.userManagement.revokeInvitation(id); + outputSuccess('Revoked invitation', invitation); + } catch (error) { + handleApiError(error); + } +} + +export async function runInvitationResend(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const invitation = await client.sdk.userManagement.resendInvitation(id); + outputSuccess('Resent invitation', invitation); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/membership.spec.ts b/src/commands/membership.spec.ts new file mode 100644 index 0000000..c2e041f --- /dev/null +++ b/src/commands/membership.spec.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock the unified client +const mockSdk = { + userManagement: { + listOrganizationMemberships: vi.fn(), + getOrganizationMembership: vi.fn(), + createOrganizationMembership: vi.fn(), + updateOrganizationMembership: vi.fn(), + deleteOrganizationMembership: vi.fn(), + deactivateOrganizationMembership: vi.fn(), + reactivateOrganizationMembership: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { + runMembershipList, + runMembershipGet, + runMembershipCreate, + runMembershipUpdate, + runMembershipDelete, + runMembershipDeactivate, + runMembershipReactivate, +} = await import('./membership.js'); + +const mockMembership = { + id: 'om_123', + userId: 'user_456', + organizationId: 'org_789', + organizationName: 'FooCorp', + role: { slug: 'admin' }, + status: 'active', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + customAttributes: {}, +}; + +describe('membership commands', () => { + let consoleOutput: string[]; + let processExitSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runMembershipList', () => { + it('lists memberships by org', async () => { + mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ + data: [mockMembership], + listMetadata: { before: null, after: null }, + }); + await runMembershipList({ org: 'org_789' }, 'sk_test'); + expect(mockSdk.userManagement.listOrganizationMemberships).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'org_789' }), + ); + expect(consoleOutput.some((l) => l.includes('om_123'))).toBe(true); + }); + + it('lists memberships by user', async () => { + mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ + data: [mockMembership], + listMetadata: { before: null, after: null }, + }); + await runMembershipList({ user: 'user_456' }, 'sk_test'); + expect(mockSdk.userManagement.listOrganizationMemberships).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user_456' }), + ); + }); + + it('exits with error when neither --org nor --user provided', async () => { + await runMembershipList({}, 'sk_test'); + expect(processExitSpy).toHaveBeenCalledWith(1); + }); + + it('handles empty results', async () => { + mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runMembershipList({ org: 'org_789' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No memberships found'))).toBe(true); + }); + + it('shows pagination cursors', async () => { + mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ + data: [mockMembership], + listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + }); + await runMembershipList({ org: 'org_789' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + }); + }); + + describe('runMembershipGet', () => { + it('fetches and prints membership as JSON', async () => { + mockSdk.userManagement.getOrganizationMembership.mockResolvedValue(mockMembership); + await runMembershipGet('om_123', 'sk_test'); + expect(mockSdk.userManagement.getOrganizationMembership).toHaveBeenCalledWith('om_123'); + expect(consoleOutput.some((l) => l.includes('om_123'))).toBe(true); + }); + }); + + describe('runMembershipCreate', () => { + it('creates membership with org and user', async () => { + mockSdk.userManagement.createOrganizationMembership.mockResolvedValue(mockMembership); + await runMembershipCreate({ org: 'org_789', user: 'user_456' }, 'sk_test'); + expect(mockSdk.userManagement.createOrganizationMembership).toHaveBeenCalledWith({ + organizationId: 'org_789', + userId: 'user_456', + }); + }); + + it('creates membership with role', async () => { + mockSdk.userManagement.createOrganizationMembership.mockResolvedValue(mockMembership); + await runMembershipCreate({ org: 'org_789', user: 'user_456', role: 'admin' }, 'sk_test'); + expect(mockSdk.userManagement.createOrganizationMembership).toHaveBeenCalledWith({ + organizationId: 'org_789', + userId: 'user_456', + roleSlug: 'admin', + }); + }); + + it('outputs created message', async () => { + mockSdk.userManagement.createOrganizationMembership.mockResolvedValue(mockMembership); + await runMembershipCreate({ org: 'org_789', user: 'user_456' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Created membership'))).toBe(true); + }); + }); + + describe('runMembershipUpdate', () => { + it('updates membership role', async () => { + mockSdk.userManagement.updateOrganizationMembership.mockResolvedValue(mockMembership); + await runMembershipUpdate('om_123', 'editor', 'sk_test'); + expect(mockSdk.userManagement.updateOrganizationMembership).toHaveBeenCalledWith('om_123', { + roleSlug: 'editor', + }); + }); + }); + + describe('runMembershipDelete', () => { + it('deletes membership and prints confirmation', async () => { + mockSdk.userManagement.deleteOrganizationMembership.mockResolvedValue(undefined); + await runMembershipDelete('om_123', 'sk_test'); + expect(mockSdk.userManagement.deleteOrganizationMembership).toHaveBeenCalledWith('om_123'); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('om_123'))).toBe(true); + }); + }); + + describe('runMembershipDeactivate', () => { + it('deactivates membership', async () => { + const deactivated = { ...mockMembership, status: 'inactive' }; + mockSdk.userManagement.deactivateOrganizationMembership.mockResolvedValue(deactivated); + await runMembershipDeactivate('om_123', 'sk_test'); + expect(mockSdk.userManagement.deactivateOrganizationMembership).toHaveBeenCalledWith('om_123'); + expect(consoleOutput.some((l) => l.includes('Deactivated membership'))).toBe(true); + }); + }); + + describe('runMembershipReactivate', () => { + it('reactivates membership', async () => { + mockSdk.userManagement.reactivateOrganizationMembership.mockResolvedValue(mockMembership); + await runMembershipReactivate('om_123', 'sk_test'); + expect(mockSdk.userManagement.reactivateOrganizationMembership).toHaveBeenCalledWith('om_123'); + expect(consoleOutput.some((l) => l.includes('Reactivated membership'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runMembershipGet outputs raw JSON', async () => { + mockSdk.userManagement.getOrganizationMembership.mockResolvedValue(mockMembership); + await runMembershipGet('om_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('om_123'); + expect(output).not.toHaveProperty('status', 'ok'); + }); + + it('runMembershipList outputs JSON with data and listMetadata', async () => { + mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ + data: [mockMembership], + listMetadata: { before: null, after: 'cursor_a' }, + }); + await runMembershipList({ org: 'org_789' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].id).toBe('om_123'); + expect(output.listMetadata.after).toBe('cursor_a'); + }); + + it('runMembershipList outputs empty data array for no results', async () => { + mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runMembershipList({ org: 'org_789' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + expect(output.listMetadata).toBeDefined(); + }); + + it('runMembershipCreate outputs JSON success', async () => { + mockSdk.userManagement.createOrganizationMembership.mockResolvedValue(mockMembership); + await runMembershipCreate({ org: 'org_789', user: 'user_456' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Created membership'); + expect(output.data.id).toBe('om_123'); + }); + + it('runMembershipUpdate outputs JSON success', async () => { + mockSdk.userManagement.updateOrganizationMembership.mockResolvedValue(mockMembership); + await runMembershipUpdate('om_123', 'admin', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('om_123'); + }); + + it('runMembershipDelete outputs JSON success', async () => { + mockSdk.userManagement.deleteOrganizationMembership.mockResolvedValue(undefined); + await runMembershipDelete('om_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('om_123'); + }); + + it('runMembershipDeactivate outputs JSON success', async () => { + const deactivated = { ...mockMembership, status: 'inactive' }; + mockSdk.userManagement.deactivateOrganizationMembership.mockResolvedValue(deactivated); + await runMembershipDeactivate('om_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Deactivated membership'); + }); + + it('runMembershipReactivate outputs JSON success', async () => { + mockSdk.userManagement.reactivateOrganizationMembership.mockResolvedValue(mockMembership); + await runMembershipReactivate('om_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Reactivated membership'); + }); + }); +}); diff --git a/src/commands/membership.ts b/src/commands/membership.ts new file mode 100644 index 0000000..4e22342 --- /dev/null +++ b/src/commands/membership.ts @@ -0,0 +1,173 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode, exitWithError } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Membership'); + +export interface MembershipListOptions { + org?: string; + user?: string; + limit?: number; + before?: string; + after?: string; + order?: string; +} + +export async function runMembershipList( + options: MembershipListOptions, + apiKey: string, + baseUrl?: string, +): Promise { + if (!options.org && !options.user) { + exitWithError({ + code: 'missing_args', + message: 'At least one of --org or --user is required.', + }); + } + + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.userManagement.listOrganizationMemberships({ + ...(options.org && { organizationId: options.org }), + ...(options.user && { userId: options.user }), + limit: options.limit, + before: options.before, + after: options.after, + order: options.order as 'asc' | 'desc' | undefined, + } as Parameters[0]); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No memberships found.'); + return; + } + + const rows = result.data.map((m) => [ + m.id, + m.userId, + m.organizationId, + m.role?.slug ?? chalk.dim('-'), + m.status, + m.createdAt, + ]); + + console.log( + formatTable( + [ + { header: 'ID' }, + { header: 'User ID' }, + { header: 'Org ID' }, + { header: 'Role' }, + { header: 'Status' }, + { header: 'Created' }, + ], + rows, + ), + ); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runMembershipGet(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const membership = await client.sdk.userManagement.getOrganizationMembership(id); + outputJson(membership); + } catch (error) { + handleApiError(error); + } +} + +export interface MembershipCreateOptions { + org: string; + user: string; + role?: string; +} + +export async function runMembershipCreate( + options: MembershipCreateOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const membership = await client.sdk.userManagement.createOrganizationMembership({ + organizationId: options.org, + userId: options.user, + ...(options.role && { roleSlug: options.role }), + }); + outputSuccess('Created membership', membership); + } catch (error) { + handleApiError(error); + } +} + +export async function runMembershipUpdate( + id: string, + role: string | undefined, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const membership = await client.sdk.userManagement.updateOrganizationMembership(id, { + ...(role && { roleSlug: role }), + }); + outputSuccess('Updated membership', membership); + } catch (error) { + handleApiError(error); + } +} + +export async function runMembershipDelete(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.userManagement.deleteOrganizationMembership(id); + outputSuccess('Deleted membership', { id }); + } catch (error) { + handleApiError(error); + } +} + +export async function runMembershipDeactivate(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const membership = await client.sdk.userManagement.deactivateOrganizationMembership(id); + outputSuccess('Deactivated membership', membership); + } catch (error) { + handleApiError(error); + } +} + +export async function runMembershipReactivate(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const membership = await client.sdk.userManagement.reactivateOrganizationMembership(id); + outputSuccess('Reactivated membership', membership); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/onboard-user.spec.ts b/src/commands/onboard-user.spec.ts new file mode 100644 index 0000000..1b3edbb --- /dev/null +++ b/src/commands/onboard-user.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSdk = { + userManagement: { + sendInvitation: vi.fn(), + getInvitation: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { runOnboardUser } = await import('./onboard-user.js'); + +describe('onboard-user command', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('sends invitation with email and org', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue({ id: 'inv_123', state: 'pending' }); + + await runOnboardUser({ email: 'alice@acme.com', org: 'org_123' }, 'sk_test'); + + expect(mockSdk.userManagement.sendInvitation).toHaveBeenCalledWith({ + email: 'alice@acme.com', + organizationId: 'org_123', + }); + expect(consoleOutput.some((l) => l.includes('inv_123'))).toBe(true); + }); + + it('sends invitation with role', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue({ id: 'inv_123', state: 'pending' }); + + await runOnboardUser({ email: 'alice@acme.com', org: 'org_123', role: 'admin' }, 'sk_test'); + + expect(mockSdk.userManagement.sendInvitation).toHaveBeenCalledWith(expect.objectContaining({ roleSlug: 'admin' })); + }); + + it('does not poll when --wait is not set', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue({ id: 'inv_123', state: 'pending' }); + + await runOnboardUser({ email: 'alice@acme.com', org: 'org_123' }, 'sk_test'); + + expect(mockSdk.userManagement.getInvitation).not.toHaveBeenCalled(); + }); + + it('polls invitation status when --wait is set until accepted', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue({ id: 'inv_123', state: 'pending' }); + mockSdk.userManagement.getInvitation + .mockResolvedValueOnce({ id: 'inv_123', state: 'pending' }) + .mockResolvedValueOnce({ id: 'inv_123', state: 'accepted' }); + + const promise = runOnboardUser({ email: 'alice@acme.com', org: 'org_123', wait: true }, 'sk_test'); + await vi.advanceTimersByTimeAsync(5000); + await vi.advanceTimersByTimeAsync(5000); + await promise; + + expect(mockSdk.userManagement.getInvitation).toHaveBeenCalledTimes(2); + expect(consoleOutput.some((l) => l.includes('accepted'))).toBe(true); + }); + + it('stops polling when invitation is revoked', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue({ id: 'inv_123', state: 'pending' }); + mockSdk.userManagement.getInvitation.mockResolvedValueOnce({ id: 'inv_123', state: 'revoked' }); + + const promise = runOnboardUser({ email: 'alice@acme.com', org: 'org_123', wait: true }, 'sk_test'); + await vi.advanceTimersByTimeAsync(5000); + await promise; + + expect(mockSdk.userManagement.getInvitation).toHaveBeenCalledTimes(1); + expect(consoleOutput.some((l) => l.includes('revoked'))).toBe(true); + }); + + it('stops polling when invitation is expired', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue({ id: 'inv_123', state: 'pending' }); + mockSdk.userManagement.getInvitation.mockResolvedValueOnce({ id: 'inv_123', state: 'expired' }); + + const promise = runOnboardUser({ email: 'alice@acme.com', org: 'org_123', wait: true }, 'sk_test'); + await vi.advanceTimersByTimeAsync(5000); + await promise; + + expect(consoleOutput.some((l) => l.includes('expired'))).toBe(true); + }); + + it('prints human-mode summary with invitation details', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue({ id: 'inv_123', state: 'pending' }); + + await runOnboardUser({ email: 'alice@acme.com', org: 'org_123', role: 'admin' }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('Onboarding summary'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('inv_123'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('admin'))).toBe(true); + }); + + describe('JSON mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('outputs JSON summary with invitation ID', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue({ id: 'inv_123', state: 'pending' }); + + await runOnboardUser({ email: 'alice@acme.com', org: 'org_123' }, 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.invitationId).toBe('inv_123'); + }); + + it('includes acceptance status when --wait resolves', async () => { + mockSdk.userManagement.sendInvitation.mockResolvedValue({ id: 'inv_123', state: 'pending' }); + mockSdk.userManagement.getInvitation.mockResolvedValueOnce({ id: 'inv_123', state: 'accepted' }); + + const promise = runOnboardUser({ email: 'alice@acme.com', org: 'org_123', wait: true }, 'sk_test'); + await vi.advanceTimersByTimeAsync(5000); + await promise; + + const output = JSON.parse(consoleOutput[0]); + expect(output.invitationAccepted).toBe(true); + }); + }); +}); diff --git a/src/commands/onboard-user.ts b/src/commands/onboard-user.ts new file mode 100644 index 0000000..cd88e4a --- /dev/null +++ b/src/commands/onboard-user.ts @@ -0,0 +1,69 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('OnboardUser'); + +export interface OnboardUserOptions { + email: string; + org: string; + role?: string; + wait?: boolean; +} + +export async function runOnboardUser(options: OnboardUserOptions, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + const summary: Record = {}; + + try { + if (!isJsonMode()) console.log(chalk.bold(`Onboarding user: ${options.email}`)); + + // 1. Send invitation + const invitation = await client.sdk.userManagement.sendInvitation({ + email: options.email, + organizationId: options.org, + ...(options.role && { roleSlug: options.role }), + }); + summary.invitationId = invitation.id; + if (!isJsonMode()) console.log(chalk.green(` Sent invitation: ${invitation.id}`)); + + // 2. Optional: wait for acceptance + if (options.wait) { + if (!isJsonMode()) console.log(chalk.dim(' Waiting for invitation acceptance...')); + + const maxAttempts = 60; + const pollInterval = 5000; + + for (let i = 0; i < maxAttempts; i++) { + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + const status = await client.sdk.userManagement.getInvitation(invitation.id); + + if (status.state === 'accepted') { + summary.invitationAccepted = true; + if (!isJsonMode()) console.log(chalk.green(' Invitation accepted!')); + break; + } + + if (status.state === 'revoked' || status.state === 'expired') { + summary.invitationAccepted = false; + if (!isJsonMode()) console.log(chalk.yellow(` Invitation ${status.state}.`)); + break; + } + + if (!isJsonMode() && i % 6 === 0) console.log(chalk.dim(` Still waiting... (${status.state})`)); + } + } + + // 3. Print summary + if (isJsonMode()) { + outputJson({ status: 'ok', ...summary }); + } else { + console.log(chalk.bold('\nOnboarding summary:')); + console.log(` Invitation: ${invitation.id} (${invitation.state})`); + if (options.role) console.log(` Role: ${options.role}`); + } + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/org-domain.spec.ts b/src/commands/org-domain.spec.ts new file mode 100644 index 0000000..f65375f --- /dev/null +++ b/src/commands/org-domain.spec.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSdk = { + organizationDomains: { + get: vi.fn(), + create: vi.fn(), + verify: vi.fn(), + delete: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { runOrgDomainGet, runOrgDomainCreate, runOrgDomainVerify, runOrgDomainDelete } = await import('./org-domain.js'); + +const mockDomain = { + id: 'org_domain_123', + domain: 'example.com', + organizationId: 'org_456', + state: 'verified', + verificationStrategy: 'dns', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +describe('org-domain commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runOrgDomainGet', () => { + it('fetches domain by ID', async () => { + mockSdk.organizationDomains.get.mockResolvedValue(mockDomain); + await runOrgDomainGet('org_domain_123', 'sk_test'); + expect(mockSdk.organizationDomains.get).toHaveBeenCalledWith('org_domain_123'); + expect(consoleOutput.some((l) => l.includes('org_domain_123'))).toBe(true); + }); + }); + + describe('runOrgDomainCreate', () => { + it('creates domain with correct params', async () => { + mockSdk.organizationDomains.create.mockResolvedValue(mockDomain); + await runOrgDomainCreate('example.com', 'org_456', 'sk_test'); + expect(mockSdk.organizationDomains.create).toHaveBeenCalledWith({ + domain: 'example.com', + organizationId: 'org_456', + }); + }); + + it('outputs success message', async () => { + mockSdk.organizationDomains.create.mockResolvedValue(mockDomain); + await runOrgDomainCreate('example.com', 'org_456', 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Created organization domain'))).toBe(true); + }); + }); + + describe('runOrgDomainVerify', () => { + it('verifies domain by ID', async () => { + mockSdk.organizationDomains.verify.mockResolvedValue(mockDomain); + await runOrgDomainVerify('org_domain_123', 'sk_test'); + expect(mockSdk.organizationDomains.verify).toHaveBeenCalledWith('org_domain_123'); + }); + + it('outputs success message', async () => { + mockSdk.organizationDomains.verify.mockResolvedValue(mockDomain); + await runOrgDomainVerify('org_domain_123', 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Verified organization domain'))).toBe(true); + }); + }); + + describe('runOrgDomainDelete', () => { + it('deletes domain by ID', async () => { + mockSdk.organizationDomains.delete.mockResolvedValue(undefined); + await runOrgDomainDelete('org_domain_123', 'sk_test'); + expect(mockSdk.organizationDomains.delete).toHaveBeenCalledWith('org_domain_123'); + }); + + it('outputs deletion confirmation', async () => { + mockSdk.organizationDomains.delete.mockResolvedValue(undefined); + await runOrgDomainDelete('org_domain_123', 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('org_domain_123'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('runOrgDomainGet outputs raw JSON', async () => { + mockSdk.organizationDomains.get.mockResolvedValue(mockDomain); + await runOrgDomainGet('org_domain_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('org_domain_123'); + expect(output.domain).toBe('example.com'); + }); + + it('runOrgDomainCreate outputs JSON success', async () => { + mockSdk.organizationDomains.create.mockResolvedValue(mockDomain); + await runOrgDomainCreate('example.com', 'org_456', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('org_domain_123'); + }); + + it('runOrgDomainDelete outputs JSON success', async () => { + mockSdk.organizationDomains.delete.mockResolvedValue(undefined); + await runOrgDomainDelete('org_domain_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('org_domain_123'); + }); + }); +}); diff --git a/src/commands/org-domain.ts b/src/commands/org-domain.ts new file mode 100644 index 0000000..c43769f --- /dev/null +++ b/src/commands/org-domain.ts @@ -0,0 +1,54 @@ +import { createWorkOSClient } from '../lib/workos-client.js'; +import { outputSuccess, outputJson } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('OrganizationDomain'); + +export async function runOrgDomainGet(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.organizationDomains.get(id); + outputJson(result); + } catch (error) { + handleApiError(error); + } +} + +export async function runOrgDomainCreate( + domain: string, + organizationId: string, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.organizationDomains.create({ domain, organizationId }); + outputSuccess('Created organization domain', result); + } catch (error) { + handleApiError(error); + } +} + +export async function runOrgDomainVerify(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.organizationDomains.verify(id); + outputSuccess('Verified organization domain', result); + } catch (error) { + handleApiError(error); + } +} + +export async function runOrgDomainDelete(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.organizationDomains.delete(id); + outputSuccess('Deleted organization domain', { id }); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/organization.spec.ts b/src/commands/organization.spec.ts index 698e592..6753b89 100644 --- a/src/commands/organization.spec.ts +++ b/src/commands/organization.spec.ts @@ -1,23 +1,20 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -// Mock workos-api -vi.mock('../lib/workos-api.js', () => ({ - workosRequest: vi.fn(), - WorkOSApiError: class WorkOSApiError extends Error { - constructor( - message: string, - public readonly statusCode: number, - public readonly code?: string, - public readonly errors?: Array<{ message: string }>, - ) { - super(message); - this.name = 'WorkOSApiError'; - } +// Mock the unified client +const mockSdk = { + organizations: { + createOrganization: vi.fn(), + updateOrganization: vi.fn(), + getOrganization: vi.fn(), + listOrganizations: vi.fn(), + deleteOrganization: vi.fn(), }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), })); -const { workosRequest } = await import('../lib/workos-api.js'); -const mockRequest = vi.mocked(workosRequest); const { setOutputMode } = await import('../utils/output.js'); const { runOrgCreate, runOrgUpdate, runOrgGet, runOrgList, runOrgDelete, parseDomainArgs } = @@ -27,7 +24,7 @@ describe('organization commands', () => { let consoleOutput: string[]; beforeEach(() => { - mockRequest.mockReset(); + vi.clearAllMocks(); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); @@ -60,32 +57,22 @@ describe('organization commands', () => { describe('runOrgCreate', () => { it('creates org with name only', async () => { - mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); await runOrgCreate('Test', [], 'sk_test'); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'POST', - path: '/organizations', - body: { name: 'Test' }, - }), - ); + expect(mockSdk.organizations.createOrganization).toHaveBeenCalledWith({ name: 'Test' }); }); it('creates org with domain data', async () => { - mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); await runOrgCreate('Test', ['foo.com:pending'], 'sk_test'); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - body: { - name: 'Test', - domain_data: [{ domain: 'foo.com', state: 'pending' }], - }, - }), - ); + expect(mockSdk.organizations.createOrganization).toHaveBeenCalledWith({ + name: 'Test', + domainData: [{ domain: 'foo.com', state: 'pending' }], + }); }); it('outputs created message and JSON', async () => { - mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); await runOrgCreate('Test', [], 'sk_test'); expect(consoleOutput.some((l) => l.includes('Created organization'))).toBe(true); }); @@ -93,46 +80,37 @@ describe('organization commands', () => { describe('runOrgUpdate', () => { it('updates org name', async () => { - mockRequest.mockResolvedValue({ id: 'org_123', name: 'Updated' }); + mockSdk.organizations.updateOrganization.mockResolvedValue({ id: 'org_123', name: 'Updated' }); await runOrgUpdate('org_123', 'Updated', 'sk_test'); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'PUT', - path: '/organizations/org_123', - body: { name: 'Updated' }, - }), - ); + expect(mockSdk.organizations.updateOrganization).toHaveBeenCalledWith({ + organization: 'org_123', + name: 'Updated', + }); }); it('updates org with domain data', async () => { - mockRequest.mockResolvedValue({ id: 'org_123', name: 'Updated' }); + mockSdk.organizations.updateOrganization.mockResolvedValue({ id: 'org_123', name: 'Updated' }); await runOrgUpdate('org_123', 'Updated', 'sk_test', 'foo.com', 'pending'); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - body: { - name: 'Updated', - domain_data: [{ domain: 'foo.com', state: 'pending' }], - }, - }), - ); + expect(mockSdk.organizations.updateOrganization).toHaveBeenCalledWith({ + organization: 'org_123', + name: 'Updated', + domainData: [{ domain: 'foo.com', state: 'pending' }], + }); }); }); describe('runOrgGet', () => { it('fetches and prints org as JSON', async () => { - mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); + mockSdk.organizations.getOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); await runOrgGet('org_123', 'sk_test'); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ method: 'GET', path: '/organizations/org_123' }), - ); - // Should print JSON + expect(mockSdk.organizations.getOrganization).toHaveBeenCalledWith('org_123'); expect(consoleOutput.some((l) => l.includes('org_123'))).toBe(true); }); }); describe('runOrgList', () => { it('lists orgs in table format', async () => { - mockRequest.mockResolvedValue({ + mockSdk.organizations.listOrganizations.mockResolvedValue({ data: [ { id: 'org_123', @@ -140,34 +118,37 @@ describe('organization commands', () => { domains: [{ id: 'd_1', domain: 'foo.com', state: 'verified' }], }, ], - list_metadata: { before: null, after: null }, + listMetadata: { before: null, after: null }, }); await runOrgList({}, 'sk_test'); - expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ method: 'GET', path: '/organizations' })); - // Should contain table data + expect(mockSdk.organizations.listOrganizations).toHaveBeenCalled(); expect(consoleOutput.some((l) => l.includes('FooCorp'))).toBe(true); }); it('passes filter params', async () => { - mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } }); + mockSdk.organizations.listOrganizations.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); await runOrgList({ domain: 'foo.com', limit: 5, order: 'desc' }, 'sk_test'); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ domains: 'foo.com', limit: 5, order: 'desc' }), - }), + expect(mockSdk.organizations.listOrganizations).toHaveBeenCalledWith( + expect.objectContaining({ domains: ['foo.com'], limit: 5, order: 'desc' }), ); }); it('handles empty results', async () => { - mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } }); + mockSdk.organizations.listOrganizations.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); await runOrgList({}, 'sk_test'); expect(consoleOutput.some((l) => l.includes('No organizations found'))).toBe(true); }); it('shows pagination cursors', async () => { - mockRequest.mockResolvedValue({ + mockSdk.organizations.listOrganizations.mockResolvedValue({ data: [{ id: 'org_1', name: 'Test', domains: [] }], - list_metadata: { before: 'cursor_b', after: 'cursor_a' }, + listMetadata: { before: 'cursor_b', after: 'cursor_a' }, }); await runOrgList({}, 'sk_test'); expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); @@ -177,11 +158,9 @@ describe('organization commands', () => { describe('runOrgDelete', () => { it('deletes org and prints confirmation', async () => { - mockRequest.mockResolvedValue(null); + mockSdk.organizations.deleteOrganization.mockResolvedValue(undefined); await runOrgDelete('org_123', 'sk_test'); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ method: 'DELETE', path: '/organizations/org_123' }), - ); + expect(mockSdk.organizations.deleteOrganization).toHaveBeenCalledWith('org_123'); expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); expect(consoleOutput.some((l) => l.includes('org_123'))).toBe(true); }); @@ -197,7 +176,7 @@ describe('organization commands', () => { }); it('runOrgCreate outputs JSON success', async () => { - mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); await runOrgCreate('Test', [], 'sk_test'); const output = JSON.parse(consoleOutput[0]); expect(output.status).toBe('ok'); @@ -206,7 +185,7 @@ describe('organization commands', () => { }); it('runOrgGet outputs raw JSON', async () => { - mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); + mockSdk.organizations.getOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); await runOrgGet('org_123', 'sk_test'); const output = JSON.parse(consoleOutput[0]); expect(output.id).toBe('org_123'); @@ -214,28 +193,31 @@ describe('organization commands', () => { expect(output).not.toHaveProperty('status'); }); - it('runOrgList outputs JSON with data and list_metadata', async () => { - mockRequest.mockResolvedValue({ + it('runOrgList outputs JSON with data and listMetadata', async () => { + mockSdk.organizations.listOrganizations.mockResolvedValue({ data: [{ id: 'org_123', name: 'FooCorp', domains: [] }], - list_metadata: { before: null, after: 'cursor_a' }, + listMetadata: { before: null, after: 'cursor_a' }, }); await runOrgList({}, 'sk_test'); const output = JSON.parse(consoleOutput[0]); expect(output.data).toHaveLength(1); expect(output.data[0].id).toBe('org_123'); - expect(output.list_metadata.after).toBe('cursor_a'); + expect(output.listMetadata.after).toBe('cursor_a'); }); it('runOrgList outputs empty data array for no results', async () => { - mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } }); + mockSdk.organizations.listOrganizations.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); await runOrgList({}, 'sk_test'); const output = JSON.parse(consoleOutput[0]); expect(output.data).toEqual([]); - expect(output.list_metadata).toBeDefined(); + expect(output.listMetadata).toBeDefined(); }); it('runOrgDelete outputs JSON success', async () => { - mockRequest.mockResolvedValue(null); + mockSdk.organizations.deleteOrganization.mockResolvedValue(undefined); await runOrgDelete('org_123', 'sk_test'); const output = JSON.parse(consoleOutput[0]); expect(output.status).toBe('ok'); diff --git a/src/commands/organization.ts b/src/commands/organization.ts index 672f7ed..f66e46b 100644 --- a/src/commands/organization.ts +++ b/src/commands/organization.ts @@ -1,35 +1,16 @@ import chalk from 'chalk'; -import { workosRequest } from '../lib/workos-api.js'; -import type { WorkOSListResponse } from '../lib/workos-api.js'; +import type { DomainData } from '@workos-inc/node'; +import { createWorkOSClient } from '../lib/workos-client.js'; import { formatTable } from '../utils/table.js'; import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; import { createApiErrorHandler } from '../lib/api-error-handler.js'; -interface OrganizationDomain { - id: string; - domain: string; - state: 'verified' | 'pending'; -} - -interface Organization { - id: string; - name: string; - domains: OrganizationDomain[]; - created_at: string; - updated_at: string; -} - -interface DomainData { - domain: string; - state: string; -} - export function parseDomainArgs(args: string[]): DomainData[] { return args.map((arg) => { const parts = arg.split(':'); return { domain: parts[0], - state: parts[1] || 'verified', + state: (parts[1] || 'verified') as DomainData['state'], }; }); } @@ -42,19 +23,13 @@ export async function runOrgCreate( apiKey: string, baseUrl?: string, ): Promise { - const body: Record = { name }; + const client = createWorkOSClient(apiKey, baseUrl); const domains = parseDomainArgs(domainArgs); - if (domains.length > 0) { - body.domain_data = domains; - } try { - const org = await workosRequest({ - method: 'POST', - path: '/organizations', - apiKey, - baseUrl, - body, + const org = await client.sdk.organizations.createOrganization({ + name, + ...(domains.length > 0 && { domainData: domains }), }); outputSuccess('Created organization', org); } catch (error) { @@ -70,18 +45,13 @@ export async function runOrgUpdate( state?: string, baseUrl?: string, ): Promise { - const body: Record = { name }; - if (domain) { - body.domain_data = [{ domain, state: state || 'verified' }]; - } + const client = createWorkOSClient(apiKey, baseUrl); try { - const org = await workosRequest({ - method: 'PUT', - path: `/organizations/${orgId}`, - apiKey, - baseUrl, - body, + const org = await client.sdk.organizations.updateOrganization({ + organization: orgId, + name, + ...(domain && { domainData: [{ domain, state: (state || 'verified') as DomainData['state'] }] }), }); outputSuccess('Updated organization', org); } catch (error) { @@ -90,13 +60,10 @@ export async function runOrgUpdate( } export async function runOrgGet(orgId: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + try { - const org = await workosRequest({ - method: 'GET', - path: `/organizations/${orgId}`, - apiKey, - baseUrl, - }); + const org = await client.sdk.organizations.getOrganization(orgId); outputJson(org); } catch (error) { handleApiError(error); @@ -112,23 +79,19 @@ export interface OrgListOptions { } export async function runOrgList(options: OrgListOptions, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + try { - const result = await workosRequest>({ - method: 'GET', - path: '/organizations', - apiKey, - baseUrl, - params: { - domains: options.domain, - limit: options.limit, - before: options.before, - after: options.after, - order: options.order, - }, + const result = await client.sdk.organizations.listOrganizations({ + ...(options.domain && { domains: [options.domain] }), + limit: options.limit, + before: options.before, + after: options.after, + order: options.order as 'asc' | 'desc' | undefined, }); if (isJsonMode()) { - outputJson({ data: result.data, list_metadata: result.list_metadata }); + outputJson({ data: result.data, listMetadata: result.listMetadata }); return; } @@ -145,7 +108,7 @@ export async function runOrgList(options: OrgListOptions, apiKey: string, baseUr console.log(formatTable([{ header: 'ID' }, { header: 'Name' }, { header: 'Domains' }], rows)); - const { before, after } = result.list_metadata; + const { before, after } = result.listMetadata; if (before && after) { console.log(chalk.dim(`Before: ${before} After: ${after}`)); } else if (before) { @@ -159,13 +122,10 @@ export async function runOrgList(options: OrgListOptions, apiKey: string, baseUr } export async function runOrgDelete(orgId: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + try { - await workosRequest({ - method: 'DELETE', - path: `/organizations/${orgId}`, - apiKey, - baseUrl, - }); + await client.sdk.organizations.deleteOrganization(orgId); outputSuccess('Deleted organization', { id: orgId }); } catch (error) { handleApiError(error); diff --git a/src/commands/permission.spec.ts b/src/commands/permission.spec.ts new file mode 100644 index 0000000..2fc0058 --- /dev/null +++ b/src/commands/permission.spec.ts @@ -0,0 +1,261 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock the unified client +const mockSdk = { + authorization: { + listPermissions: vi.fn(), + getPermission: vi.fn(), + createPermission: vi.fn(), + updatePermission: vi.fn(), + deletePermission: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { runPermissionList, runPermissionGet, runPermissionCreate, runPermissionUpdate, runPermissionDelete } = + await import('./permission.js'); + +describe('permission commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runPermissionList', () => { + it('lists permissions in table format', async () => { + mockSdk.authorization.listPermissions.mockResolvedValue({ + data: [ + { + id: 'perm_123', + slug: 'read-users', + name: 'Read Users', + description: 'Can read user data', + createdAt: '2024-01-01T00:00:00Z', + }, + ], + listMetadata: { before: null, after: null }, + }); + await runPermissionList({}, 'sk_test'); + expect(mockSdk.authorization.listPermissions).toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('read-users'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('Read Users'))).toBe(true); + }); + + it('passes pagination params', async () => { + mockSdk.authorization.listPermissions.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runPermissionList({ limit: 5, order: 'desc', after: 'cursor_a' }, 'sk_test'); + expect(mockSdk.authorization.listPermissions).toHaveBeenCalledWith( + expect.objectContaining({ limit: 5, order: 'desc', after: 'cursor_a' }), + ); + }); + + it('handles empty results', async () => { + mockSdk.authorization.listPermissions.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runPermissionList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No permissions found'))).toBe(true); + }); + + it('shows pagination cursors', async () => { + mockSdk.authorization.listPermissions.mockResolvedValue({ + data: [ + { + id: 'perm_1', + slug: 'read', + name: 'Read', + description: null, + createdAt: '2024-01-01T00:00:00Z', + }, + ], + listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + }); + await runPermissionList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + }); + }); + + describe('runPermissionGet', () => { + it('fetches and prints permission as JSON', async () => { + mockSdk.authorization.getPermission.mockResolvedValue({ + id: 'perm_123', + slug: 'read-users', + name: 'Read Users', + description: 'Can read user data', + }); + await runPermissionGet('read-users', 'sk_test'); + expect(mockSdk.authorization.getPermission).toHaveBeenCalledWith('read-users'); + expect(consoleOutput.some((l) => l.includes('perm_123'))).toBe(true); + }); + }); + + describe('runPermissionCreate', () => { + it('creates permission with slug and name', async () => { + mockSdk.authorization.createPermission.mockResolvedValue({ + id: 'perm_123', + slug: 'read-users', + name: 'Read Users', + }); + await runPermissionCreate({ slug: 'read-users', name: 'Read Users' }, 'sk_test'); + expect(mockSdk.authorization.createPermission).toHaveBeenCalledWith({ + slug: 'read-users', + name: 'Read Users', + }); + }); + + it('includes description when provided', async () => { + mockSdk.authorization.createPermission.mockResolvedValue({ + id: 'perm_123', + slug: 'read-users', + name: 'Read Users', + }); + await runPermissionCreate({ slug: 'read-users', name: 'Read Users', description: 'Desc' }, 'sk_test'); + expect(mockSdk.authorization.createPermission).toHaveBeenCalledWith({ + slug: 'read-users', + name: 'Read Users', + description: 'Desc', + }); + }); + + it('outputs created message', async () => { + mockSdk.authorization.createPermission.mockResolvedValue({ + id: 'perm_123', + slug: 'read-users', + name: 'Read Users', + }); + await runPermissionCreate({ slug: 'read-users', name: 'Read Users' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Created permission'))).toBe(true); + }); + }); + + describe('runPermissionUpdate', () => { + it('updates permission with provided fields', async () => { + mockSdk.authorization.updatePermission.mockResolvedValue({ + id: 'perm_123', + slug: 'read-users', + name: 'Updated Name', + }); + await runPermissionUpdate('read-users', { name: 'Updated Name' }, 'sk_test'); + expect(mockSdk.authorization.updatePermission).toHaveBeenCalledWith('read-users', { name: 'Updated Name' }); + }); + + it('sends only provided fields', async () => { + mockSdk.authorization.updatePermission.mockResolvedValue({ + id: 'perm_123', + slug: 'read-users', + name: 'Read Users', + description: 'New desc', + }); + await runPermissionUpdate('read-users', { description: 'New desc' }, 'sk_test'); + expect(mockSdk.authorization.updatePermission).toHaveBeenCalledWith('read-users', { description: 'New desc' }); + }); + }); + + describe('runPermissionDelete', () => { + it('deletes permission and prints confirmation', async () => { + mockSdk.authorization.deletePermission.mockResolvedValue(undefined); + await runPermissionDelete('read-users', 'sk_test'); + expect(mockSdk.authorization.deletePermission).toHaveBeenCalledWith('read-users'); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('read-users'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runPermissionCreate outputs JSON success', async () => { + mockSdk.authorization.createPermission.mockResolvedValue({ + id: 'perm_123', + slug: 'read-users', + name: 'Read Users', + }); + await runPermissionCreate({ slug: 'read-users', name: 'Read Users' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Created permission'); + expect(output.data.id).toBe('perm_123'); + }); + + it('runPermissionGet outputs raw JSON', async () => { + mockSdk.authorization.getPermission.mockResolvedValue({ + id: 'perm_123', + slug: 'read-users', + name: 'Read Users', + }); + await runPermissionGet('read-users', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('perm_123'); + expect(output.slug).toBe('read-users'); + expect(output).not.toHaveProperty('status'); + }); + + it('runPermissionList outputs JSON with data and listMetadata', async () => { + mockSdk.authorization.listPermissions.mockResolvedValue({ + data: [{ id: 'perm_123', slug: 'read-users', name: 'Read Users' }], + listMetadata: { before: null, after: 'cursor_a' }, + }); + await runPermissionList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].id).toBe('perm_123'); + expect(output.listMetadata.after).toBe('cursor_a'); + }); + + it('runPermissionList outputs empty data array for no results', async () => { + mockSdk.authorization.listPermissions.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runPermissionList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + expect(output.listMetadata).toBeDefined(); + }); + + it('runPermissionUpdate outputs JSON success', async () => { + mockSdk.authorization.updatePermission.mockResolvedValue({ + id: 'perm_123', + slug: 'read-users', + name: 'Updated', + }); + await runPermissionUpdate('read-users', { name: 'Updated' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.name).toBe('Updated'); + }); + + it('runPermissionDelete outputs JSON success', async () => { + mockSdk.authorization.deletePermission.mockResolvedValue(undefined); + await runPermissionDelete('read-users', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.slug).toBe('read-users'); + }); + }); +}); diff --git a/src/commands/permission.ts b/src/commands/permission.ts new file mode 100644 index 0000000..46af96a --- /dev/null +++ b/src/commands/permission.ts @@ -0,0 +1,134 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Permission'); + +export interface PermissionListOptions { + limit?: number; + before?: string; + after?: string; + order?: string; +} + +export async function runPermissionList( + options: PermissionListOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.authorization.listPermissions({ + limit: options.limit, + before: options.before, + after: options.after, + order: options.order as 'asc' | 'desc' | undefined, + }); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No permissions found.'); + return; + } + + const rows = result.data.map((perm) => [ + perm.slug, + perm.name, + perm.description || chalk.dim('-'), + new Date(perm.createdAt).toLocaleDateString(), + ]); + + console.log( + formatTable([{ header: 'Slug' }, { header: 'Name' }, { header: 'Description' }, { header: 'Created' }], rows), + ); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runPermissionGet(slug: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const permission = await client.sdk.authorization.getPermission(slug); + outputJson(permission); + } catch (error) { + handleApiError(error); + } +} + +export interface PermissionCreateOptions { + slug: string; + name: string; + description?: string; +} + +export async function runPermissionCreate( + options: PermissionCreateOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const permission = await client.sdk.authorization.createPermission({ + slug: options.slug, + name: options.name, + ...(options.description && { description: options.description }), + }); + outputSuccess('Created permission', permission); + } catch (error) { + handleApiError(error); + } +} + +export interface PermissionUpdateOptions { + name?: string; + description?: string; +} + +export async function runPermissionUpdate( + slug: string, + options: PermissionUpdateOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const permission = await client.sdk.authorization.updatePermission(slug, { + ...(options.name !== undefined && { name: options.name }), + ...(options.description !== undefined && { description: options.description }), + }); + outputSuccess('Updated permission', permission); + } catch (error) { + handleApiError(error); + } +} + +export async function runPermissionDelete(slug: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.authorization.deletePermission(slug); + outputSuccess('Deleted permission', { slug }); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/portal.spec.ts b/src/commands/portal.spec.ts new file mode 100644 index 0000000..7ea1cf9 --- /dev/null +++ b/src/commands/portal.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSdk = { + portal: { + generateLink: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { runPortalGenerateLink } = await import('./portal.js'); + +describe('portal commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runPortalGenerateLink', () => { + it('generates portal link with correct params', async () => { + mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/abc' }); + await runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }, 'sk_test'); + expect(mockSdk.portal.generateLink).toHaveBeenCalledWith( + expect.objectContaining({ intent: 'sso', organization: 'org_123' }), + ); + }); + + it('outputs link URL in human mode', async () => { + mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/abc' }); + await runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('https://portal.workos.com/abc'))).toBe(true); + }); + + it('shows expiry note in human mode', async () => { + mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/abc' }); + await runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('expire'))).toBe(true); + }); + + it('passes optional returnUrl and successUrl', async () => { + mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/abc' }); + await runPortalGenerateLink( + { + intent: 'dsync', + organization: 'org_123', + returnUrl: 'https://app.com/return', + successUrl: 'https://app.com/success', + }, + 'sk_test', + ); + expect(mockSdk.portal.generateLink).toHaveBeenCalledWith( + expect.objectContaining({ returnUrl: 'https://app.com/return', successUrl: 'https://app.com/success' }), + ); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('outputs full response object', async () => { + mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/abc' }); + await runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.link).toBe('https://portal.workos.com/abc'); + }); + }); +}); diff --git a/src/commands/portal.ts b/src/commands/portal.ts new file mode 100644 index 0000000..15eaf43 --- /dev/null +++ b/src/commands/portal.ts @@ -0,0 +1,40 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Portal'); + +export interface PortalGenerateOptions { + intent: string; + organization: string; + returnUrl?: string; + successUrl?: string; +} + +export async function runPortalGenerateLink( + options: PortalGenerateOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.portal.generateLink({ + intent: options.intent as Parameters[0]['intent'], + organization: options.organization, + ...(options.returnUrl && { returnUrl: options.returnUrl }), + ...(options.successUrl && { successUrl: options.successUrl }), + }); + + if (isJsonMode()) { + outputJson(result); + return; + } + + console.log(result.link); + console.log(chalk.dim('Note: Portal links expire after 5 minutes.')); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/role.spec.ts b/src/commands/role.spec.ts new file mode 100644 index 0000000..8b0fec1 --- /dev/null +++ b/src/commands/role.spec.ts @@ -0,0 +1,330 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock the unified client +const mockSdk = { + authorization: { + listEnvironmentRoles: vi.fn(), + listOrganizationRoles: vi.fn(), + getEnvironmentRole: vi.fn(), + getOrganizationRole: vi.fn(), + createEnvironmentRole: vi.fn(), + createOrganizationRole: vi.fn(), + updateEnvironmentRole: vi.fn(), + updateOrganizationRole: vi.fn(), + deleteOrganizationRole: vi.fn(), + setEnvironmentRolePermissions: vi.fn(), + setOrganizationRolePermissions: vi.fn(), + addEnvironmentRolePermission: vi.fn(), + addOrganizationRolePermission: vi.fn(), + removeOrganizationRolePermission: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { + runRoleList, + runRoleGet, + runRoleCreate, + runRoleUpdate, + runRoleDelete, + runRoleSetPermissions, + runRoleAddPermission, + runRoleRemovePermission, +} = await import('./role.js'); + +const mockEnvRole = { + id: 'role_123', + slug: 'admin', + name: 'Admin', + description: 'Administrator role', + type: 'EnvironmentRole', + permissions: ['read-users', 'write-users'], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +const mockOrgRole = { + id: 'role_456', + slug: 'org-admin', + name: 'Org Admin', + description: 'Organization admin role', + type: 'OrganizationRole', + permissions: ['manage-members'], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +describe('role commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runRoleList', () => { + it('lists environment roles in table format', async () => { + mockSdk.authorization.listEnvironmentRoles.mockResolvedValue({ + data: [mockEnvRole], + }); + await runRoleList(undefined, 'sk_test'); + expect(mockSdk.authorization.listEnvironmentRoles).toHaveBeenCalled(); + expect(mockSdk.authorization.listOrganizationRoles).not.toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('admin'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('Admin'))).toBe(true); + }); + + it('lists organization roles when orgId provided', async () => { + mockSdk.authorization.listOrganizationRoles.mockResolvedValue({ + data: [mockOrgRole], + }); + await runRoleList('org_abc', 'sk_test'); + expect(mockSdk.authorization.listOrganizationRoles).toHaveBeenCalledWith('org_abc'); + expect(mockSdk.authorization.listEnvironmentRoles).not.toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('org-admin'))).toBe(true); + }); + + it('handles empty results', async () => { + mockSdk.authorization.listEnvironmentRoles.mockResolvedValue({ data: [] }); + await runRoleList(undefined, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No roles found'))).toBe(true); + }); + + it('displays type and permissions count in table', async () => { + mockSdk.authorization.listEnvironmentRoles.mockResolvedValue({ + data: [mockEnvRole], + }); + await runRoleList(undefined, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('EnvironmentRole'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('2'))).toBe(true); + }); + }); + + describe('runRoleGet', () => { + it('gets environment role by slug', async () => { + mockSdk.authorization.getEnvironmentRole.mockResolvedValue(mockEnvRole); + await runRoleGet('admin', undefined, 'sk_test'); + expect(mockSdk.authorization.getEnvironmentRole).toHaveBeenCalledWith('admin'); + expect(consoleOutput.some((l) => l.includes('role_123'))).toBe(true); + }); + + it('gets organization role by slug and orgId', async () => { + mockSdk.authorization.getOrganizationRole.mockResolvedValue(mockOrgRole); + await runRoleGet('org-admin', 'org_abc', 'sk_test'); + expect(mockSdk.authorization.getOrganizationRole).toHaveBeenCalledWith('org_abc', 'org-admin'); + expect(consoleOutput.some((l) => l.includes('role_456'))).toBe(true); + }); + }); + + describe('runRoleCreate', () => { + it('creates environment role', async () => { + mockSdk.authorization.createEnvironmentRole.mockResolvedValue(mockEnvRole); + await runRoleCreate({ slug: 'admin', name: 'Admin' }, undefined, 'sk_test'); + expect(mockSdk.authorization.createEnvironmentRole).toHaveBeenCalledWith({ + slug: 'admin', + name: 'Admin', + }); + }); + + it('creates organization role when orgId provided', async () => { + mockSdk.authorization.createOrganizationRole.mockResolvedValue(mockOrgRole); + await runRoleCreate({ slug: 'org-admin', name: 'Org Admin' }, 'org_abc', 'sk_test'); + expect(mockSdk.authorization.createOrganizationRole).toHaveBeenCalledWith('org_abc', { + slug: 'org-admin', + name: 'Org Admin', + }); + }); + + it('includes description when provided', async () => { + mockSdk.authorization.createEnvironmentRole.mockResolvedValue(mockEnvRole); + await runRoleCreate({ slug: 'admin', name: 'Admin', description: 'Desc' }, undefined, 'sk_test'); + expect(mockSdk.authorization.createEnvironmentRole).toHaveBeenCalledWith({ + slug: 'admin', + name: 'Admin', + description: 'Desc', + }); + }); + + it('outputs created message', async () => { + mockSdk.authorization.createEnvironmentRole.mockResolvedValue(mockEnvRole); + await runRoleCreate({ slug: 'admin', name: 'Admin' }, undefined, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Created role'))).toBe(true); + }); + }); + + describe('runRoleUpdate', () => { + it('updates environment role', async () => { + mockSdk.authorization.updateEnvironmentRole.mockResolvedValue(mockEnvRole); + await runRoleUpdate('admin', { name: 'Updated Admin' }, undefined, 'sk_test'); + expect(mockSdk.authorization.updateEnvironmentRole).toHaveBeenCalledWith('admin', { name: 'Updated Admin' }); + }); + + it('updates organization role when orgId provided', async () => { + mockSdk.authorization.updateOrganizationRole.mockResolvedValue(mockOrgRole); + await runRoleUpdate('org-admin', { name: 'Updated' }, 'org_abc', 'sk_test'); + expect(mockSdk.authorization.updateOrganizationRole).toHaveBeenCalledWith('org_abc', 'org-admin', { + name: 'Updated', + }); + }); + + it('sends only provided fields', async () => { + mockSdk.authorization.updateEnvironmentRole.mockResolvedValue(mockEnvRole); + await runRoleUpdate('admin', { description: 'New desc' }, undefined, 'sk_test'); + expect(mockSdk.authorization.updateEnvironmentRole).toHaveBeenCalledWith('admin', { description: 'New desc' }); + }); + }); + + describe('runRoleDelete', () => { + it('deletes organization role', async () => { + mockSdk.authorization.deleteOrganizationRole.mockResolvedValue(undefined); + await runRoleDelete('org-admin', 'org_abc', 'sk_test'); + expect(mockSdk.authorization.deleteOrganizationRole).toHaveBeenCalledWith('org_abc', 'org-admin'); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + }); + + it('outputs confirmation with slug and org ID', async () => { + mockSdk.authorization.deleteOrganizationRole.mockResolvedValue(undefined); + await runRoleDelete('org-admin', 'org_abc', 'sk_test'); + expect(consoleOutput.some((l) => l.includes('org-admin'))).toBe(true); + }); + }); + + describe('runRoleSetPermissions', () => { + it('sets environment role permissions', async () => { + mockSdk.authorization.setEnvironmentRolePermissions.mockResolvedValue(mockEnvRole); + await runRoleSetPermissions('admin', ['read', 'write'], undefined, 'sk_test'); + expect(mockSdk.authorization.setEnvironmentRolePermissions).toHaveBeenCalledWith('admin', { + permissions: ['read', 'write'], + }); + }); + + it('sets organization role permissions', async () => { + mockSdk.authorization.setOrganizationRolePermissions.mockResolvedValue(mockOrgRole); + await runRoleSetPermissions('org-admin', ['manage'], 'org_abc', 'sk_test'); + expect(mockSdk.authorization.setOrganizationRolePermissions).toHaveBeenCalledWith('org_abc', 'org-admin', { + permissions: ['manage'], + }); + }); + + it('outputs success message', async () => { + mockSdk.authorization.setEnvironmentRolePermissions.mockResolvedValue(mockEnvRole); + await runRoleSetPermissions('admin', ['read'], undefined, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Set permissions on role'))).toBe(true); + }); + }); + + describe('runRoleAddPermission', () => { + it('adds permission to environment role', async () => { + mockSdk.authorization.addEnvironmentRolePermission.mockResolvedValue(mockEnvRole); + await runRoleAddPermission('admin', 'read-users', undefined, 'sk_test'); + expect(mockSdk.authorization.addEnvironmentRolePermission).toHaveBeenCalledWith('admin', { + permissionSlug: 'read-users', + }); + }); + + it('adds permission to organization role', async () => { + mockSdk.authorization.addOrganizationRolePermission.mockResolvedValue(mockOrgRole); + await runRoleAddPermission('org-admin', 'manage', 'org_abc', 'sk_test'); + expect(mockSdk.authorization.addOrganizationRolePermission).toHaveBeenCalledWith('org_abc', 'org-admin', { + permissionSlug: 'manage', + }); + }); + }); + + describe('runRoleRemovePermission', () => { + it('removes permission from organization role', async () => { + mockSdk.authorization.removeOrganizationRolePermission.mockResolvedValue(undefined); + await runRoleRemovePermission('org-admin', 'manage', 'org_abc', 'sk_test'); + expect(mockSdk.authorization.removeOrganizationRolePermission).toHaveBeenCalledWith('org_abc', 'org-admin', { + permissionSlug: 'manage', + }); + }); + + it('outputs confirmation', async () => { + mockSdk.authorization.removeOrganizationRolePermission.mockResolvedValue(undefined); + await runRoleRemovePermission('org-admin', 'manage', 'org_abc', 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Removed permission from role'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runRoleCreate outputs JSON success', async () => { + mockSdk.authorization.createEnvironmentRole.mockResolvedValue(mockEnvRole); + await runRoleCreate({ slug: 'admin', name: 'Admin' }, undefined, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Created role'); + expect(output.data.id).toBe('role_123'); + }); + + it('runRoleGet outputs raw JSON for env role', async () => { + mockSdk.authorization.getEnvironmentRole.mockResolvedValue(mockEnvRole); + await runRoleGet('admin', undefined, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('role_123'); + expect(output.slug).toBe('admin'); + expect(output).not.toHaveProperty('status'); + }); + + it('runRoleGet outputs raw JSON for org role', async () => { + mockSdk.authorization.getOrganizationRole.mockResolvedValue(mockOrgRole); + await runRoleGet('org-admin', 'org_abc', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('role_456'); + expect(output.type).toBe('OrganizationRole'); + }); + + it('runRoleList outputs JSON with data array', async () => { + mockSdk.authorization.listEnvironmentRoles.mockResolvedValue({ + data: [mockEnvRole], + }); + await runRoleList(undefined, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].slug).toBe('admin'); + }); + + it('runRoleList outputs empty data array for no results', async () => { + mockSdk.authorization.listEnvironmentRoles.mockResolvedValue({ data: [] }); + await runRoleList(undefined, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + }); + + it('runRoleDelete outputs JSON success', async () => { + mockSdk.authorization.deleteOrganizationRole.mockResolvedValue(undefined); + await runRoleDelete('org-admin', 'org_abc', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.slug).toBe('org-admin'); + }); + + it('runRoleSetPermissions outputs JSON success', async () => { + mockSdk.authorization.setEnvironmentRolePermissions.mockResolvedValue(mockEnvRole); + await runRoleSetPermissions('admin', ['read'], undefined, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Set permissions on role'); + }); + }); +}); diff --git a/src/commands/role.ts b/src/commands/role.ts new file mode 100644 index 0000000..7206c7d --- /dev/null +++ b/src/commands/role.ts @@ -0,0 +1,186 @@ +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Role'); + +export async function runRoleList(orgId: string | undefined, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = orgId + ? await client.sdk.authorization.listOrganizationRoles(orgId) + : await client.sdk.authorization.listEnvironmentRoles(); + + if (isJsonMode()) { + outputJson({ data: result.data }); + return; + } + + if (result.data.length === 0) { + console.log('No roles found.'); + return; + } + + const rows = result.data.map((role) => [ + role.slug, + role.name, + role.type, + String(role.permissions.length), + new Date(role.createdAt).toLocaleDateString(), + ]); + + console.log( + formatTable( + [{ header: 'Slug' }, { header: 'Name' }, { header: 'Type' }, { header: 'Permissions' }, { header: 'Created' }], + rows, + ), + ); + } catch (error) { + handleApiError(error); + } +} + +export async function runRoleGet( + slug: string, + orgId: string | undefined, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const role = orgId + ? await client.sdk.authorization.getOrganizationRole(orgId, slug) + : await client.sdk.authorization.getEnvironmentRole(slug); + outputJson(role); + } catch (error) { + handleApiError(error); + } +} + +export interface RoleCreateOptions { + slug: string; + name: string; + description?: string; +} + +export async function runRoleCreate( + options: RoleCreateOptions, + orgId: string | undefined, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + const opts = { + slug: options.slug, + name: options.name, + ...(options.description && { description: options.description }), + }; + + try { + const role = orgId + ? await client.sdk.authorization.createOrganizationRole(orgId, opts) + : await client.sdk.authorization.createEnvironmentRole(opts); + outputSuccess('Created role', role); + } catch (error) { + handleApiError(error); + } +} + +export interface RoleUpdateOptions { + name?: string; + description?: string; +} + +export async function runRoleUpdate( + slug: string, + options: RoleUpdateOptions, + orgId: string | undefined, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + const opts = { + ...(options.name !== undefined && { name: options.name }), + ...(options.description !== undefined && { description: options.description }), + }; + + try { + const role = orgId + ? await client.sdk.authorization.updateOrganizationRole(orgId, slug, opts) + : await client.sdk.authorization.updateEnvironmentRole(slug, opts); + outputSuccess('Updated role', role); + } catch (error) { + handleApiError(error); + } +} + +export async function runRoleDelete(slug: string, orgId: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.authorization.deleteOrganizationRole(orgId, slug); + outputSuccess('Deleted role', { slug, organizationId: orgId }); + } catch (error) { + handleApiError(error); + } +} + +export async function runRoleSetPermissions( + slug: string, + permissions: string[], + orgId: string | undefined, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const role = orgId + ? await client.sdk.authorization.setOrganizationRolePermissions(orgId, slug, { permissions }) + : await client.sdk.authorization.setEnvironmentRolePermissions(slug, { permissions }); + outputSuccess('Set permissions on role', role); + } catch (error) { + handleApiError(error); + } +} + +export async function runRoleAddPermission( + slug: string, + permissionSlug: string, + orgId: string | undefined, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const role = orgId + ? await client.sdk.authorization.addOrganizationRolePermission(orgId, slug, { permissionSlug }) + : await client.sdk.authorization.addEnvironmentRolePermission(slug, { permissionSlug }); + outputSuccess('Added permission to role', role); + } catch (error) { + handleApiError(error); + } +} + +export async function runRoleRemovePermission( + slug: string, + permissionSlug: string, + orgId: string, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.authorization.removeOrganizationRolePermission(orgId, slug, { permissionSlug }); + outputSuccess('Removed permission from role', { slug, permissionSlug, organizationId: orgId }); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/seed.spec.ts b/src/commands/seed.spec.ts new file mode 100644 index 0000000..221e579 --- /dev/null +++ b/src/commands/seed.spec.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +const mockSdk = { + authorization: { + createPermission: vi.fn(), + deletePermission: vi.fn(), + createEnvironmentRole: vi.fn(), + setEnvironmentRolePermissions: vi.fn(), + }, + organizations: { + createOrganization: vi.fn(), + deleteOrganization: vi.fn(), + }, +}; + +const mockExtensions = { + redirectUris: { add: vi.fn() }, + corsOrigins: { add: vi.fn() }, + homepageUrl: { set: vi.fn() }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk, ...mockExtensions }), +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { runSeed } = await import('./seed.js'); + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockWriteFileSync = vi.mocked(writeFileSync); +const mockUnlinkSync = vi.mocked(unlinkSync); + +const FULL_SEED_YAML = ` +organizations: + - name: "Test Org" + domains: ["test.com"] +permissions: + - name: "Read Users" + slug: "read-users" + - name: "Write Users" + slug: "write-users" +roles: + - name: "Admin" + slug: "admin" + permissions: ["read-users", "write-users"] + - name: "Viewer" + slug: "viewer" + permissions: ["read-users"] +config: + redirect_uris: ["http://localhost:3000/callback"] + cors_origins: ["http://localhost:3000"] + homepage_url: "http://localhost:3000" +`; + +describe('seed command', () => { + let consoleOutput: string[]; + let consoleErrors: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + consoleErrors = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + describe('runSeed with --file', () => { + it('creates resources in dependency order: permissions → roles → orgs → config', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(FULL_SEED_YAML); + mockSdk.authorization.createPermission.mockResolvedValue({ slug: 'read-users' }); + mockSdk.authorization.createEnvironmentRole.mockResolvedValue({ slug: 'admin' }); + mockSdk.authorization.setEnvironmentRolePermissions.mockResolvedValue({}); + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Test Org' }); + mockExtensions.redirectUris.add.mockResolvedValue({ success: true, alreadyExists: false }); + mockExtensions.corsOrigins.add.mockResolvedValue({ success: true, alreadyExists: false }); + mockExtensions.homepageUrl.set.mockResolvedValue(undefined); + + await runSeed({ file: 'workos-seed.yml' }, 'sk_test'); + + // Permissions created first + expect(mockSdk.authorization.createPermission).toHaveBeenCalledTimes(2); + expect(mockSdk.authorization.createPermission).toHaveBeenCalledWith( + expect.objectContaining({ slug: 'read-users' }), + ); + expect(mockSdk.authorization.createPermission).toHaveBeenCalledWith( + expect.objectContaining({ slug: 'write-users' }), + ); + + // Then roles + expect(mockSdk.authorization.createEnvironmentRole).toHaveBeenCalledTimes(2); + + // Then permission assignments + expect(mockSdk.authorization.setEnvironmentRolePermissions).toHaveBeenCalledWith('admin', { + permissions: ['read-users', 'write-users'], + }); + expect(mockSdk.authorization.setEnvironmentRolePermissions).toHaveBeenCalledWith('viewer', { + permissions: ['read-users'], + }); + + // Then orgs + expect(mockSdk.organizations.createOrganization).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Test Org' }), + ); + + // Then config + expect(mockExtensions.redirectUris.add).toHaveBeenCalledWith('http://localhost:3000/callback'); + expect(mockExtensions.corsOrigins.add).toHaveBeenCalledWith('http://localhost:3000'); + expect(mockExtensions.homepageUrl.set).toHaveBeenCalledWith('http://localhost:3000'); + + // State file written + expect(mockWriteFileSync).toHaveBeenCalled(); + const stateArg = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(stateArg.permissions).toHaveLength(2); + expect(stateArg.roles).toHaveLength(2); + expect(stateArg.organizations).toHaveLength(1); + }); + + it('skips already-existing permissions without failing', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +permissions: + - name: "Existing" + slug: "existing" +`); + mockSdk.authorization.createPermission.mockRejectedValue(new Error('already exists')); + + await runSeed({ file: 'workos-seed.yml' }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('exists'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('Seed complete'))).toBe(true); + }); + + it('skips already-existing roles without failing', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +roles: + - name: "Existing" + slug: "existing" +`); + mockSdk.authorization.createEnvironmentRole.mockRejectedValue(new Error('conflict')); + + await runSeed({ file: 'workos-seed.yml' }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('exists'))).toBe(true); + }); + + it('skips already-existing orgs without failing', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +organizations: + - name: "Existing Org" +`); + mockSdk.organizations.createOrganization.mockRejectedValue(new Error('duplicate')); + + await runSeed({ file: 'workos-seed.yml' }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('exist'))).toBe(true); + }); + + it('handles permission assignment failure gracefully', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +roles: + - name: "Admin" + slug: "admin" + permissions: ["nonexistent"] +`); + mockSdk.authorization.createEnvironmentRole.mockResolvedValue({ slug: 'admin' }); + mockSdk.authorization.setEnvironmentRolePermissions.mockRejectedValue(new Error('Permission not found')); + + await runSeed({ file: 'workos-seed.yml' }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('Warning') || l.includes('Failed to set permissions'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('Seed complete'))).toBe(true); + }); + + it('handles config with already-existing URIs', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +config: + redirect_uris: ["http://localhost:3000/callback"] +`); + mockExtensions.redirectUris.add.mockResolvedValue({ success: true, alreadyExists: true }); + + await runSeed({ file: 'workos-seed.yml' }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('exists'))).toBe(true); + }); + + it('saves partial state on failure', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(FULL_SEED_YAML); + mockSdk.authorization.createPermission.mockResolvedValue({ slug: 'read-users' }); + mockSdk.authorization.createEnvironmentRole.mockRejectedValue(new Error('Server exploded')); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + await runSeed({ file: 'workos-seed.yml' }, 'sk_test'); + + // State should be saved with the permission that was created + expect(mockWriteFileSync).toHaveBeenCalled(); + const stateArg = JSON.parse(mockWriteFileSync.mock.calls[0][1] as string); + expect(stateArg.permissions.length).toBeGreaterThan(0); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('exits with error when file not found', async () => { + mockExistsSync.mockReturnValue(false); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + await runSeed({ file: 'missing.yml' }, 'sk_test'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('exits with error when no --file provided', async () => { + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + await runSeed({}, 'sk_test'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('exits with error on invalid YAML', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{{{{invalid yaml'); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + await runSeed({ file: 'bad.yml' }, 'sk_test'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); + + describe('runSeed --clean', () => { + it('deletes resources in reverse order: orgs → permissions', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + permissions: [{ slug: 'read-users' }, { slug: 'write-users' }], + roles: [{ slug: 'admin' }], + organizations: [{ id: 'org_123', name: 'Test Org' }], + createdAt: '2024-01-01', + }), + ); + mockSdk.organizations.deleteOrganization.mockResolvedValue(undefined); + mockSdk.authorization.deletePermission.mockResolvedValue(undefined); + + await runSeed({ clean: true }, 'sk_test'); + + expect(mockSdk.organizations.deleteOrganization).toHaveBeenCalledWith('org_123'); + expect(mockSdk.authorization.deletePermission).toHaveBeenCalledWith('write-users'); + expect(mockSdk.authorization.deletePermission).toHaveBeenCalledWith('read-users'); + expect(mockUnlinkSync).toHaveBeenCalled(); + }); + + it('skips env roles (cannot be deleted)', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + permissions: [], + roles: [{ slug: 'admin' }], + organizations: [], + createdAt: '2024-01-01', + }), + ); + + await runSeed({ clean: true }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('skipped') || l.includes('cannot be deleted'))).toBe(true); + }); + + it('handles delete failures gracefully', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + permissions: [{ slug: 'stuck' }], + roles: [], + organizations: [{ id: 'org_stuck', name: 'Stuck Org' }], + createdAt: '2024-01-01', + }), + ); + mockSdk.organizations.deleteOrganization.mockRejectedValue(new Error('Cannot delete')); + mockSdk.authorization.deletePermission.mockRejectedValue(new Error('Cannot delete')); + + await runSeed({ clean: true }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('Warning'))).toBe(true); + // Should still remove state file + expect(mockUnlinkSync).toHaveBeenCalled(); + }); + + it('exits with error when no state file', async () => { + mockExistsSync.mockReturnValue(false); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + await runSeed({ clean: true }, 'sk_test'); + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('outputs JSON status with state on seed success', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(` +permissions: + - name: "Test" + slug: "test" +`); + mockSdk.authorization.createPermission.mockResolvedValue({ slug: 'test' }); + + await runSeed({ file: 'seed.yml' }, 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Seed complete'); + expect(output.state.permissions).toHaveLength(1); + }); + + it('outputs JSON success on clean', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ permissions: [], roles: [], organizations: [], createdAt: '2024-01-01' }), + ); + + await runSeed({ clean: true }, 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.message).toBe('Seed cleanup complete'); + }); + }); +}); diff --git a/src/commands/seed.ts b/src/commands/seed.ts new file mode 100644 index 0000000..bc38742 --- /dev/null +++ b/src/commands/seed.ts @@ -0,0 +1,260 @@ +import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs'; +import chalk from 'chalk'; +import { parse as parseYaml } from 'yaml'; +import type { DomainData } from '@workos-inc/node'; +import { createWorkOSClient, type WorkOSCLIClient } from '../lib/workos-client.js'; +import { outputJson, outputSuccess, isJsonMode, exitWithError } from '../utils/output.js'; + +const STATE_FILE = '.workos-seed-state.json'; + +interface SeedConfig { + organizations?: Array<{ name: string; domains?: string[] }>; + permissions?: Array<{ name: string; slug: string; description?: string }>; + roles?: Array<{ name: string; slug: string; description?: string; permissions?: string[] }>; + config?: { + redirect_uris?: string[]; + cors_origins?: string[]; + homepage_url?: string; + }; +} + +interface SeedState { + permissions: Array<{ slug: string }>; + roles: Array<{ slug: string }>; + organizations: Array<{ id: string; name: string }>; + createdAt: string; +} + +function loadState(): SeedState | null { + if (!existsSync(STATE_FILE)) return null; + try { + return JSON.parse(readFileSync(STATE_FILE, 'utf-8')); + } catch { + return null; + } +} + +function saveState(state: SeedState): void { + writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} + +export async function runSeed( + options: { file?: string; clean?: boolean }, + apiKey: string, + baseUrl?: string, +): Promise { + if (options.clean) { + await runSeedClean(apiKey, baseUrl); + return; + } + + if (!options.file) { + return exitWithError({ + code: 'missing_args', + message: 'Provide a seed file: workos seed --file=workos-seed.yml', + }); + } + + if (!existsSync(options.file)) { + return exitWithError({ + code: 'file_not_found', + message: `Seed file not found: ${options.file}. Create workos-seed.yml or run \`workos seed\` without --file for interactive mode.`, + }); + } + + const raw = readFileSync(options.file, 'utf-8'); + let seedConfig: SeedConfig; + try { + seedConfig = parseYaml(raw) as SeedConfig; + } catch (error) { + exitWithError({ + code: 'invalid_yaml', + message: `Failed to parse seed file: ${error instanceof Error ? error.message : 'Invalid YAML'}`, + }); + } + + const client = createWorkOSClient(apiKey, baseUrl); + const state: SeedState = { permissions: [], roles: [], organizations: [], createdAt: new Date().toISOString() }; + + try { + // 1. Create permissions + if (seedConfig.permissions) { + for (const perm of seedConfig.permissions) { + try { + await client.sdk.authorization.createPermission({ + slug: perm.slug, + name: perm.name, + ...(perm.description && { description: perm.description }), + }); + state.permissions.push({ slug: perm.slug }); + if (!isJsonMode()) console.log(chalk.green(` Created permission: ${perm.slug}`)); + } catch (error: unknown) { + if (isAlreadyExists(error)) { + if (!isJsonMode()) console.log(chalk.dim(` Permission exists: ${perm.slug} (skipped)`)); + } else { + throw error; + } + } + } + } + + // 2. Create roles + assign permissions + if (seedConfig.roles) { + for (const role of seedConfig.roles) { + try { + await client.sdk.authorization.createEnvironmentRole({ + slug: role.slug, + name: role.name, + ...(role.description && { description: role.description }), + }); + state.roles.push({ slug: role.slug }); + if (!isJsonMode()) console.log(chalk.green(` Created role: ${role.slug}`)); + } catch (error: unknown) { + if (isAlreadyExists(error)) { + if (!isJsonMode()) console.log(chalk.dim(` Role exists: ${role.slug} (skipped)`)); + } else { + throw error; + } + } + + if (role.permissions?.length) { + try { + await client.sdk.authorization.setEnvironmentRolePermissions(role.slug, { + permissions: role.permissions, + }); + if (!isJsonMode()) + console.log(chalk.green(` Set permissions on ${role.slug}: ${role.permissions.join(', ')}`)); + } catch { + if (!isJsonMode()) console.log(chalk.yellow(` Warning: Failed to set permissions on ${role.slug}`)); + } + } + } + } + + // 3. Create organizations + if (seedConfig.organizations) { + for (const org of seedConfig.organizations) { + try { + const created = await client.sdk.organizations.createOrganization({ + name: org.name, + ...(org.domains?.length && { + domainData: org.domains.map((d) => ({ domain: d, state: 'verified' as DomainData['state'] })), + }), + }); + state.organizations.push({ id: created.id, name: created.name }); + if (!isJsonMode()) console.log(chalk.green(` Created org: ${created.name} (${created.id})`)); + } catch (error: unknown) { + if (isAlreadyExists(error)) { + if (!isJsonMode()) console.log(chalk.dim(` Org may exist: ${org.name} (skipped)`)); + } else { + throw error; + } + } + } + } + + // 4. Configure redirect URIs, CORS, homepage + if (seedConfig.config) { + await applyConfig(client, seedConfig.config); + } + + saveState(state); + + if (isJsonMode()) { + outputJson({ status: 'ok', message: 'Seed complete', state }); + } else { + console.log(chalk.green('\nSeed complete.')); + console.log(chalk.dim(`State saved to ${STATE_FILE}`)); + } + } catch (error) { + // Partial failure — save what was created so --clean can tear down + saveState(state); + exitWithError({ + code: 'seed_failed', + message: `Seed failed: ${error instanceof Error ? error.message : 'Unknown error'}. Partial state saved to ${STATE_FILE}. Run \`workos seed --clean\` to tear down.`, + details: state, + }); + } +} + +async function runSeedClean(apiKey: string, baseUrl?: string): Promise { + const state = loadState(); + if (!state) { + return exitWithError({ + code: 'no_state', + message: `No seed state found (${STATE_FILE}). Nothing to clean.`, + }); + } + + const client = createWorkOSClient(apiKey, baseUrl); + + // Delete in reverse order: orgs → roles → permissions + for (const org of state.organizations.reverse()) { + try { + await client.sdk.organizations.deleteOrganization(org.id); + if (!isJsonMode()) console.log(chalk.green(` Deleted org: ${org.name} (${org.id})`)); + } catch { + if (!isJsonMode()) console.log(chalk.yellow(` Warning: Could not delete org ${org.id}`)); + } + } + + for (const role of state.roles.reverse()) { + try { + // Env roles can't be deleted via SDK — skip silently + if (!isJsonMode()) console.log(chalk.dim(` Env role ${role.slug}: skipped (env roles cannot be deleted)`)); + } catch { + // ignore + } + } + + for (const perm of state.permissions.reverse()) { + try { + await client.sdk.authorization.deletePermission(perm.slug); + if (!isJsonMode()) console.log(chalk.green(` Deleted permission: ${perm.slug}`)); + } catch { + if (!isJsonMode()) console.log(chalk.yellow(` Warning: Could not delete permission ${perm.slug}`)); + } + } + + unlinkSync(STATE_FILE); + outputSuccess('Seed cleanup complete', { stateFile: STATE_FILE }); +} + +async function applyConfig(client: WorkOSCLIClient, config: NonNullable): Promise { + if (config.redirect_uris) { + for (const uri of config.redirect_uris) { + const result = await client.redirectUris.add(uri); + if (!isJsonMode()) { + console.log( + result.alreadyExists + ? chalk.dim(` Redirect URI exists: ${uri}`) + : chalk.green(` Added redirect URI: ${uri}`), + ); + } + } + } + + if (config.cors_origins) { + for (const origin of config.cors_origins) { + const result = await client.corsOrigins.add(origin); + if (!isJsonMode()) { + console.log( + result.alreadyExists + ? chalk.dim(` CORS origin exists: ${origin}`) + : chalk.green(` Added CORS origin: ${origin}`), + ); + } + } + } + + if (config.homepage_url) { + await client.homepageUrl.set(config.homepage_url); + if (!isJsonMode()) console.log(chalk.green(` Set homepage URL: ${config.homepage_url}`)); + } +} + +function isAlreadyExists(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const msg = error.message.toLowerCase(); + return msg.includes('already exists') || msg.includes('conflict') || msg.includes('duplicate'); +} diff --git a/src/commands/session.spec.ts b/src/commands/session.spec.ts new file mode 100644 index 0000000..969e684 --- /dev/null +++ b/src/commands/session.spec.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock the unified client +const mockSdk = { + userManagement: { + listSessions: vi.fn(), + revokeSession: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { runSessionList, runSessionRevoke } = await import('./session.js'); + +const mockSession = { + id: 'session_123', + userId: 'user_456', + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + authMethod: 'password', + status: 'active', + expiresAt: '2024-02-01T00:00:00Z', + endedAt: null, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', +}; + +describe('session commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runSessionList', () => { + it('lists sessions for a user', async () => { + mockSdk.userManagement.listSessions.mockResolvedValue({ + data: [mockSession], + listMetadata: { before: null, after: null }, + }); + await runSessionList('user_456', {}, 'sk_test'); + expect(mockSdk.userManagement.listSessions).toHaveBeenCalledWith('user_456', expect.any(Object)); + expect(consoleOutput.some((l) => l.includes('session_123'))).toBe(true); + }); + + it('handles empty results', async () => { + mockSdk.userManagement.listSessions.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runSessionList('user_456', {}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No sessions found'))).toBe(true); + }); + + it('shows pagination cursors', async () => { + mockSdk.userManagement.listSessions.mockResolvedValue({ + data: [mockSession], + listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + }); + await runSessionList('user_456', {}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + }); + + it('displays user agent and IP in table', async () => { + mockSdk.userManagement.listSessions.mockResolvedValue({ + data: [mockSession], + listMetadata: { before: null, after: null }, + }); + await runSessionList('user_456', {}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Mozilla/5.0'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('192.168.1.1'))).toBe(true); + }); + }); + + describe('runSessionRevoke', () => { + it('revokes session and prints confirmation', async () => { + mockSdk.userManagement.revokeSession.mockResolvedValue(undefined); + await runSessionRevoke('session_123', 'sk_test'); + expect(mockSdk.userManagement.revokeSession).toHaveBeenCalledWith({ sessionId: 'session_123' }); + expect(consoleOutput.some((l) => l.includes('Revoked session'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('session_123'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('runSessionList outputs JSON with data and listMetadata', async () => { + mockSdk.userManagement.listSessions.mockResolvedValue({ + data: [mockSession], + listMetadata: { before: null, after: 'cursor_a' }, + }); + await runSessionList('user_456', {}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.data[0].id).toBe('session_123'); + expect(output.listMetadata.after).toBe('cursor_a'); + }); + + it('runSessionList outputs empty data array for no results', async () => { + mockSdk.userManagement.listSessions.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runSessionList('user_456', {}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + expect(output.listMetadata).toBeDefined(); + }); + + it('runSessionRevoke outputs JSON success', async () => { + mockSdk.userManagement.revokeSession.mockResolvedValue(undefined); + await runSessionRevoke('session_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('session_123'); + }); + }); +}); diff --git a/src/commands/session.ts b/src/commands/session.ts new file mode 100644 index 0000000..684b0c9 --- /dev/null +++ b/src/commands/session.ts @@ -0,0 +1,85 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Session'); + +export interface SessionListOptions { + limit?: number; + before?: string; + after?: string; + order?: string; +} + +export async function runSessionList( + userId: string, + options: SessionListOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.userManagement.listSessions(userId, { + limit: options.limit, + before: options.before, + after: options.after, + order: options.order as 'asc' | 'desc' | undefined, + }); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No sessions found.'); + return; + } + + const rows = result.data.map((s) => [ + s.id, + s.userAgent ?? chalk.dim('-'), + s.ipAddress ?? chalk.dim('-'), + s.createdAt, + s.expiresAt, + ]); + + console.log( + formatTable( + [ + { header: 'ID' }, + { header: 'User Agent' }, + { header: 'IP Address' }, + { header: 'Created' }, + { header: 'Expires' }, + ], + rows, + ), + ); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runSessionRevoke(sessionId: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.userManagement.revokeSession({ sessionId }); + outputSuccess('Revoked session', { id: sessionId }); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/setup-org.spec.ts b/src/commands/setup-org.spec.ts new file mode 100644 index 0000000..802c861 --- /dev/null +++ b/src/commands/setup-org.spec.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSdk = { + organizations: { createOrganization: vi.fn() }, + organizationDomains: { create: vi.fn(), verify: vi.fn() }, + authorization: { createOrganizationRole: vi.fn() }, + portal: { generateLink: vi.fn() }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { runSetupOrg } = await import('./setup-org.js'); + +describe('setup-org command', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('creates org with name only', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + await runSetupOrg({ name: 'Acme' }, 'sk_test'); + expect(mockSdk.organizations.createOrganization).toHaveBeenCalledWith({ name: 'Acme' }); + expect(consoleOutput.some((l) => l.includes('org_123'))).toBe(true); + }); + + it('does not call domain or role APIs when not provided', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + await runSetupOrg({ name: 'Acme' }, 'sk_test'); + expect(mockSdk.organizationDomains.create).not.toHaveBeenCalled(); + expect(mockSdk.authorization.createOrganizationRole).not.toHaveBeenCalled(); + }); + + it('adds and verifies domain when provided', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + mockSdk.organizationDomains.create.mockResolvedValue({ id: 'dom_1' }); + mockSdk.organizationDomains.verify.mockResolvedValue({ id: 'dom_1', state: 'verified' }); + + await runSetupOrg({ name: 'Acme', domain: 'acme.com' }, 'sk_test'); + + expect(mockSdk.organizationDomains.create).toHaveBeenCalledWith({ domain: 'acme.com', organizationId: 'org_123' }); + expect(mockSdk.organizationDomains.verify).toHaveBeenCalledWith('dom_1'); + expect(consoleOutput.some((l) => l.includes('Verified domain'))).toBe(true); + }); + + it('handles domain verification failure gracefully', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + mockSdk.organizationDomains.create.mockResolvedValue({ id: 'dom_1' }); + mockSdk.organizationDomains.verify.mockRejectedValue(new Error('Verification pending')); + + await runSetupOrg({ name: 'Acme', domain: 'acme.com' }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('pending'))).toBe(true); + }); + + it('creates org-scoped roles when provided', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + mockSdk.authorization.createOrganizationRole.mockResolvedValue({ slug: 'admin' }); + + await runSetupOrg({ name: 'Acme', roles: ['admin', 'viewer'] }, 'sk_test'); + + expect(mockSdk.authorization.createOrganizationRole).toHaveBeenCalledTimes(2); + expect(mockSdk.authorization.createOrganizationRole).toHaveBeenCalledWith('org_123', { + slug: 'admin', + name: 'admin', + }); + expect(mockSdk.authorization.createOrganizationRole).toHaveBeenCalledWith('org_123', { + slug: 'viewer', + name: 'viewer', + }); + }); + + it('skips already-existing roles without failing', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + mockSdk.authorization.createOrganizationRole.mockRejectedValue(new Error('Role already exists')); + + await runSetupOrg({ name: 'Acme', roles: ['existing'] }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('exists') || l.includes('skipped'))).toBe(true); + }); + + it('handles role creation failure gracefully', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + mockSdk.authorization.createOrganizationRole.mockRejectedValue(new Error('Server error')); + + await runSetupOrg({ name: 'Acme', roles: ['bad'] }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('Warning') || l.includes('Could not create'))).toBe(true); + }); + + it('generates portal link', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/xxx' }); + + await runSetupOrg({ name: 'Acme' }, 'sk_test'); + + expect(mockSdk.portal.generateLink).toHaveBeenCalledWith(expect.objectContaining({ organization: 'org_123' })); + expect(consoleOutput.some((l) => l.includes('portal.workos.com'))).toBe(true); + }); + + it('handles portal link failure gracefully', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + mockSdk.portal.generateLink.mockRejectedValue(new Error('Plan upgrade required')); + + await runSetupOrg({ name: 'Acme' }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('skipped'))).toBe(true); + }); + + it('prints human-mode summary with all components', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + mockSdk.organizationDomains.create.mockResolvedValue({ id: 'dom_1' }); + mockSdk.organizationDomains.verify.mockResolvedValue({}); + mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/xxx' }); + + await runSetupOrg({ name: 'Acme', domain: 'acme.com' }, 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('Setup complete'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('org_123'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('acme.com'))).toBe(true); + }); + + describe('JSON mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('outputs JSON summary with org ID', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/xxx' }); + + await runSetupOrg({ name: 'Acme' }, 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.organizationId).toBe('org_123'); + expect(output.portalLink).toBe('https://portal.workos.com/xxx'); + }); + + it('includes domain verification status in JSON', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + mockSdk.organizationDomains.create.mockResolvedValue({ id: 'dom_1' }); + mockSdk.organizationDomains.verify.mockResolvedValue({}); + + await runSetupOrg({ name: 'Acme', domain: 'acme.com' }, 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.domainId).toBe('dom_1'); + expect(output.domainVerified).toBe(true); + }); + + it('includes roles in JSON', async () => { + mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Acme' }); + mockSdk.authorization.createOrganizationRole.mockResolvedValue({ slug: 'admin' }); + + await runSetupOrg({ name: 'Acme', roles: ['admin'] }, 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.roles).toContain('admin'); + }); + }); +}); diff --git a/src/commands/setup-org.ts b/src/commands/setup-org.ts new file mode 100644 index 0000000..70583f7 --- /dev/null +++ b/src/commands/setup-org.ts @@ -0,0 +1,97 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('SetupOrg'); + +export interface SetupOrgOptions { + name: string; + domain?: string; + roles?: string[]; +} + +export async function runSetupOrg(options: SetupOrgOptions, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + const summary: Record = {}; + + try { + // 1. Create organization + if (!isJsonMode()) console.log(chalk.bold(`Setting up organization: ${options.name}`)); + + const org = await client.sdk.organizations.createOrganization({ + name: options.name, + }); + summary.organizationId = org.id; + if (!isJsonMode()) console.log(chalk.green(` Created org: ${org.name} (${org.id})`)); + + // 2. Add domain + if (options.domain) { + const domainResult = await client.sdk.organizationDomains.create({ + domain: options.domain, + organizationId: org.id, + }); + summary.domainId = domainResult.id; + if (!isJsonMode()) console.log(chalk.green(` Added domain: ${options.domain}`)); + + // 3. Verify domain + try { + await client.sdk.organizationDomains.verify(domainResult.id); + summary.domainVerified = true; + if (!isJsonMode()) console.log(chalk.green(` Verified domain: ${options.domain}`)); + } catch { + summary.domainVerified = false; + if (!isJsonMode()) console.log(chalk.yellow(` Domain verification pending: ${options.domain}`)); + } + } + + // 4. Create org-scoped roles (copy from env role names) + if (options.roles?.length) { + summary.roles = []; + for (const roleSlug of options.roles) { + try { + const role = await client.sdk.authorization.createOrganizationRole(org.id, { + slug: roleSlug, + name: roleSlug, + }); + (summary.roles as string[]).push(role.slug); + if (!isJsonMode()) console.log(chalk.green(` Created org role: ${roleSlug}`)); + } catch (error: unknown) { + if (error instanceof Error && error.message.toLowerCase().includes('already exists')) { + if (!isJsonMode()) console.log(chalk.dim(` Role exists: ${roleSlug} (skipped)`)); + } else { + if (!isJsonMode()) console.log(chalk.yellow(` Warning: Could not create role ${roleSlug}`)); + } + } + } + } + + // 5. Generate Admin Portal link + try { + const portal = await client.sdk.portal.generateLink({ + intent: 'sso' as Parameters[0]['intent'], + organization: org.id, + }); + summary.portalLink = portal.link; + if (!isJsonMode()) { + console.log(chalk.green(` Portal link: ${portal.link}`)); + console.log(chalk.dim(' Note: Portal links expire after 5 minutes.')); + } + } catch { + if (!isJsonMode()) console.log(chalk.dim(' Portal link: skipped (may require plan upgrade)')); + } + + // 6. Print summary + if (isJsonMode()) { + outputJson({ status: 'ok', ...summary }); + } else { + console.log(chalk.bold('\nSetup complete:')); + console.log(` Organization: ${org.id}`); + if (options.domain) + console.log(` Domain: ${options.domain} (${summary.domainVerified ? 'verified' : 'pending'})`); + if (summary.portalLink) console.log(` Portal: ${summary.portalLink}`); + } + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/user.spec.ts b/src/commands/user.spec.ts index 8a14aad..593ba31 100644 --- a/src/commands/user.spec.ts +++ b/src/commands/user.spec.ts @@ -1,22 +1,19 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -vi.mock('../lib/workos-api.js', () => ({ - workosRequest: vi.fn(), - WorkOSApiError: class WorkOSApiError extends Error { - constructor( - message: string, - public readonly statusCode: number, - public readonly code?: string, - public readonly errors?: Array<{ message: string }>, - ) { - super(message); - this.name = 'WorkOSApiError'; - } +// Mock the unified client +const mockSdk = { + userManagement: { + getUser: vi.fn(), + listUsers: vi.fn(), + updateUser: vi.fn(), + deleteUser: vi.fn(), }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), })); -const { workosRequest } = await import('../lib/workos-api.js'); -const mockRequest = vi.mocked(workosRequest); const { setOutputMode } = await import('../utils/output.js'); const { runUserGet, runUserList, runUserUpdate, runUserDelete } = await import('./user.js'); @@ -25,7 +22,7 @@ describe('user commands', () => { let consoleOutput: string[]; beforeEach(() => { - mockRequest.mockReset(); + vi.clearAllMocks(); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); @@ -38,47 +35,55 @@ describe('user commands', () => { describe('runUserGet', () => { it('fetches and prints user as JSON', async () => { - mockRequest.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); + mockSdk.userManagement.getUser.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); await runUserGet('user_123', 'sk_test'); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ method: 'GET', path: '/user_management/users/user_123' }), - ); + expect(mockSdk.userManagement.getUser).toHaveBeenCalledWith('user_123'); expect(consoleOutput.some((l) => l.includes('user_123'))).toBe(true); }); }); describe('runUserList', () => { it('lists users in table format', async () => { - mockRequest.mockResolvedValue({ + mockSdk.userManagement.listUsers.mockResolvedValue({ data: [ - { id: 'user_123', email: 'test@example.com', first_name: 'Test', last_name: 'User', email_verified: true }, + { + id: 'user_123', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + emailVerified: true, + }, ], - list_metadata: { before: null, after: null }, + listMetadata: { before: null, after: null }, }); await runUserList({}, 'sk_test'); expect(consoleOutput.some((l) => l.includes('test@example.com'))).toBe(true); }); it('passes filter params', async () => { - mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } }); + mockSdk.userManagement.listUsers.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); await runUserList({ email: 'test@example.com', organization: 'org_123', limit: 5 }, 'sk_test'); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ email: 'test@example.com', organization_id: 'org_123', limit: 5 }), - }), + expect(mockSdk.userManagement.listUsers).toHaveBeenCalledWith( + expect.objectContaining({ email: 'test@example.com', organizationId: 'org_123', limit: 5 }), ); }); it('handles empty results', async () => { - mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } }); + mockSdk.userManagement.listUsers.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); await runUserList({}, 'sk_test'); expect(consoleOutput.some((l) => l.includes('No users found'))).toBe(true); }); it('shows pagination cursors when present', async () => { - mockRequest.mockResolvedValue({ - data: [{ id: 'user_1', email: 'a@b.com', first_name: '', last_name: '', email_verified: false }], - list_metadata: { before: 'cur_b', after: 'cur_a' }, + mockSdk.userManagement.listUsers.mockResolvedValue({ + data: [{ id: 'user_1', email: 'a@b.com', firstName: '', lastName: '', emailVerified: false }], + listMetadata: { before: 'cur_b', after: 'cur_a' }, }); await runUserList({}, 'sk_test'); expect(consoleOutput.some((l) => l.includes('cur_b'))).toBe(true); @@ -87,31 +92,30 @@ describe('user commands', () => { describe('runUserUpdate', () => { it('updates user with provided fields', async () => { - mockRequest.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); + mockSdk.userManagement.updateUser.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); await runUserUpdate('user_123', 'sk_test', { firstName: 'John', lastName: 'Doe' }); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'PUT', - path: '/user_management/users/user_123', - body: { first_name: 'John', last_name: 'Doe' }, - }), - ); + expect(mockSdk.userManagement.updateUser).toHaveBeenCalledWith({ + userId: 'user_123', + firstName: 'John', + lastName: 'Doe', + }); }); it('sends only provided fields', async () => { - mockRequest.mockResolvedValue({ id: 'user_123' }); + mockSdk.userManagement.updateUser.mockResolvedValue({ id: 'user_123' }); await runUserUpdate('user_123', 'sk_test', { emailVerified: true }); - expect(mockRequest).toHaveBeenCalledWith(expect.objectContaining({ body: { email_verified: true } })); + expect(mockSdk.userManagement.updateUser).toHaveBeenCalledWith({ + userId: 'user_123', + emailVerified: true, + }); }); }); describe('runUserDelete', () => { it('deletes user and prints confirmation', async () => { - mockRequest.mockResolvedValue(null); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); await runUserDelete('user_123', 'sk_test'); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ method: 'DELETE', path: '/user_management/users/user_123' }), - ); + expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledWith('user_123'); expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); expect(consoleOutput.some((l) => l.includes('user_123'))).toBe(true); }); @@ -127,7 +131,7 @@ describe('user commands', () => { }); it('runUserGet outputs raw JSON', async () => { - mockRequest.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); + mockSdk.userManagement.getUser.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); await runUserGet('user_123', 'sk_test'); const output = JSON.parse(consoleOutput[0]); expect(output.id).toBe('user_123'); @@ -135,30 +139,39 @@ describe('user commands', () => { expect(output).not.toHaveProperty('status'); }); - it('runUserList outputs JSON with data and list_metadata', async () => { - mockRequest.mockResolvedValue({ + it('runUserList outputs JSON with data and listMetadata', async () => { + mockSdk.userManagement.listUsers.mockResolvedValue({ data: [ - { id: 'user_123', email: 'test@example.com', first_name: 'Test', last_name: 'User', email_verified: true }, + { + id: 'user_123', + email: 'test@example.com', + firstName: 'Test', + lastName: 'User', + emailVerified: true, + }, ], - list_metadata: { before: null, after: 'cursor_a' }, + listMetadata: { before: null, after: 'cursor_a' }, }); await runUserList({}, 'sk_test'); const output = JSON.parse(consoleOutput[0]); expect(output.data).toHaveLength(1); expect(output.data[0].email).toBe('test@example.com'); - expect(output.list_metadata.after).toBe('cursor_a'); + expect(output.listMetadata.after).toBe('cursor_a'); }); it('runUserList outputs empty data array for no results', async () => { - mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } }); + mockSdk.userManagement.listUsers.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); await runUserList({}, 'sk_test'); const output = JSON.parse(consoleOutput[0]); expect(output.data).toEqual([]); - expect(output.list_metadata).toBeDefined(); + expect(output.listMetadata).toBeDefined(); }); it('runUserUpdate outputs JSON success', async () => { - mockRequest.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); + mockSdk.userManagement.updateUser.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); await runUserUpdate('user_123', 'sk_test', { firstName: 'John' }); const output = JSON.parse(consoleOutput[0]); expect(output.status).toBe('ok'); @@ -167,7 +180,7 @@ describe('user commands', () => { }); it('runUserDelete outputs JSON success', async () => { - mockRequest.mockResolvedValue(null); + mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); await runUserDelete('user_123', 'sk_test'); const output = JSON.parse(consoleOutput[0]); expect(output.status).toBe('ok'); diff --git a/src/commands/user.ts b/src/commands/user.ts index 262891c..67bba39 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,30 +1,16 @@ import chalk from 'chalk'; -import { workosRequest } from '../lib/workos-api.js'; -import type { WorkOSListResponse } from '../lib/workos-api.js'; +import { createWorkOSClient } from '../lib/workos-client.js'; import { formatTable } from '../utils/table.js'; import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; import { createApiErrorHandler } from '../lib/api-error-handler.js'; -interface User { - id: string; - email: string; - first_name: string; - last_name: string; - email_verified: boolean; - created_at: string; - updated_at: string; -} - const handleApiError = createApiErrorHandler('User'); export async function runUserGet(userId: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + try { - const user = await workosRequest({ - method: 'GET', - path: `/user_management/users/${userId}`, - apiKey, - baseUrl, - }); + const user = await client.sdk.userManagement.getUser(userId); outputJson(user); } catch (error) { handleApiError(error); @@ -41,24 +27,20 @@ export interface UserListOptions { } export async function runUserList(options: UserListOptions, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + try { - const result = await workosRequest>({ - method: 'GET', - path: '/user_management/users', - apiKey, - baseUrl, - params: { - email: options.email, - organization_id: options.organization, - limit: options.limit, - before: options.before, - after: options.after, - order: options.order, - }, + const result = await client.sdk.userManagement.listUsers({ + email: options.email, + organizationId: options.organization, + limit: options.limit, + before: options.before, + after: options.after, + order: options.order as 'asc' | 'desc' | undefined, }); if (isJsonMode()) { - outputJson({ data: result.data, list_metadata: result.list_metadata }); + outputJson({ data: result.data, listMetadata: result.listMetadata }); return; } @@ -70,9 +52,9 @@ export async function runUserList(options: UserListOptions, apiKey: string, base const rows = result.data.map((user) => [ user.id, user.email, - user.first_name || chalk.dim('-'), - user.last_name || chalk.dim('-'), - user.email_verified ? 'Yes' : 'No', + user.firstName || chalk.dim('-'), + user.lastName || chalk.dim('-'), + user.emailVerified ? 'Yes' : 'No', ]); console.log( @@ -88,7 +70,7 @@ export async function runUserList(options: UserListOptions, apiKey: string, base ), ); - const { before, after } = result.list_metadata; + const { before, after } = result.listMetadata; if (before && after) { console.log(chalk.dim(`Before: ${before} After: ${after}`)); } else if (before) { @@ -115,20 +97,16 @@ export async function runUserUpdate( options: UserUpdateOptions, baseUrl?: string, ): Promise { - const body: Record = {}; - if (options.firstName !== undefined) body.first_name = options.firstName; - if (options.lastName !== undefined) body.last_name = options.lastName; - if (options.emailVerified !== undefined) body.email_verified = options.emailVerified; - if (options.password !== undefined) body.password = options.password; - if (options.externalId !== undefined) body.external_id = options.externalId; + const client = createWorkOSClient(apiKey, baseUrl); try { - const user = await workosRequest({ - method: 'PUT', - path: `/user_management/users/${userId}`, - apiKey, - baseUrl, - body, + const user = await client.sdk.userManagement.updateUser({ + userId, + ...(options.firstName !== undefined && { firstName: options.firstName }), + ...(options.lastName !== undefined && { lastName: options.lastName }), + ...(options.emailVerified !== undefined && { emailVerified: options.emailVerified }), + ...(options.password !== undefined && { password: options.password }), + ...(options.externalId !== undefined && { externalId: options.externalId }), }); outputSuccess('Updated user', user); } catch (error) { @@ -137,13 +115,10 @@ export async function runUserUpdate( } export async function runUserDelete(userId: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + try { - await workosRequest({ - method: 'DELETE', - path: `/user_management/users/${userId}`, - apiKey, - baseUrl, - }); + await client.sdk.userManagement.deleteUser(userId); outputSuccess('Deleted user', { id: userId }); } catch (error) { handleApiError(error); diff --git a/src/commands/vault.spec.ts b/src/commands/vault.spec.ts new file mode 100644 index 0000000..4d38419 --- /dev/null +++ b/src/commands/vault.spec.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockSdk = { + vault: { + listObjects: vi.fn(), + readObject: vi.fn(), + readObjectByName: vi.fn(), + createObject: vi.fn(), + updateObject: vi.fn(), + deleteObject: vi.fn(), + describeObject: vi.fn(), + listObjectVersions: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => ({ sdk: mockSdk }), +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { + runVaultList, + runVaultGet, + runVaultGetByName, + runVaultCreate, + runVaultUpdate, + runVaultDelete, + runVaultDescribe, + runVaultListVersions, +} = await import('./vault.js'); + +const mockDigest = { id: 'obj_123', name: 'my-secret', updatedAt: '2024-01-01T00:00:00Z' }; +const mockObject = { id: 'obj_123', name: 'my-secret', value: 'secret-value', metadata: {} }; +const mockMetadata = { + id: 'obj_123', + context: {}, + environmentId: 'env_1', + keyId: 'key_1', + updatedAt: '2024-01-01', + updatedBy: 'user', + versionId: 'v1', +}; + +describe('vault commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runVaultList', () => { + it('lists objects in table', async () => { + mockSdk.vault.listObjects.mockResolvedValue({ + data: [mockDigest], + listMetadata: { before: null, after: null }, + }); + await runVaultList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('obj_123'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('my-secret'))).toBe(true); + }); + + it('passes pagination params', async () => { + mockSdk.vault.listObjects.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runVaultList({ limit: 10, order: 'asc' }, 'sk_test'); + expect(mockSdk.vault.listObjects).toHaveBeenCalledWith(expect.objectContaining({ limit: 10, order: 'asc' })); + }); + + it('handles empty results', async () => { + mockSdk.vault.listObjects.mockResolvedValue({ + data: [], + listMetadata: { before: null, after: null }, + }); + await runVaultList({}, 'sk_test'); + expect(consoleOutput.some((l) => l.includes('No vault objects found'))).toBe(true); + }); + }); + + describe('runVaultGet', () => { + it('reads object by ID', async () => { + mockSdk.vault.readObject.mockResolvedValue(mockObject); + await runVaultGet('obj_123', 'sk_test'); + expect(mockSdk.vault.readObject).toHaveBeenCalledWith({ id: 'obj_123' }); + expect(consoleOutput.some((l) => l.includes('obj_123'))).toBe(true); + }); + }); + + describe('runVaultGetByName', () => { + it('reads object by name', async () => { + mockSdk.vault.readObjectByName.mockResolvedValue(mockObject); + await runVaultGetByName('my-secret', 'sk_test'); + expect(mockSdk.vault.readObjectByName).toHaveBeenCalledWith('my-secret'); + }); + }); + + describe('runVaultCreate', () => { + it('creates object with name and value', async () => { + mockSdk.vault.createObject.mockResolvedValue(mockMetadata); + await runVaultCreate({ name: 'my-secret', value: 'secret-val' }, 'sk_test'); + expect(mockSdk.vault.createObject).toHaveBeenCalledWith({ + name: 'my-secret', + value: 'secret-val', + context: {}, + }); + expect(consoleOutput.some((l) => l.includes('Created vault object'))).toBe(true); + }); + + it('maps --org to context.organizationId', async () => { + mockSdk.vault.createObject.mockResolvedValue(mockMetadata); + await runVaultCreate({ name: 'my-secret', value: 'secret-val', org: 'org_456' }, 'sk_test'); + expect(mockSdk.vault.createObject).toHaveBeenCalledWith({ + name: 'my-secret', + value: 'secret-val', + context: { organizationId: 'org_456' }, + }); + }); + }); + + describe('runVaultUpdate', () => { + it('updates object with id and value', async () => { + mockSdk.vault.updateObject.mockResolvedValue(mockObject); + await runVaultUpdate({ id: 'obj_123', value: 'new-value' }, 'sk_test'); + expect(mockSdk.vault.updateObject).toHaveBeenCalledWith({ id: 'obj_123', value: 'new-value' }); + }); + + it('passes versionCheck when provided', async () => { + mockSdk.vault.updateObject.mockResolvedValue(mockObject); + await runVaultUpdate({ id: 'obj_123', value: 'new-value', versionCheck: 'v1' }, 'sk_test'); + expect(mockSdk.vault.updateObject).toHaveBeenCalledWith({ + id: 'obj_123', + value: 'new-value', + versionCheck: 'v1', + }); + }); + }); + + describe('runVaultDelete', () => { + it('deletes object by ID', async () => { + mockSdk.vault.deleteObject.mockResolvedValue(undefined); + await runVaultDelete('obj_123', 'sk_test'); + expect(mockSdk.vault.deleteObject).toHaveBeenCalledWith({ id: 'obj_123' }); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + }); + }); + + describe('runVaultDescribe', () => { + it('describes object by ID', async () => { + mockSdk.vault.describeObject.mockResolvedValue(mockObject); + await runVaultDescribe('obj_123', 'sk_test'); + expect(mockSdk.vault.describeObject).toHaveBeenCalledWith({ id: 'obj_123' }); + }); + }); + + describe('runVaultListVersions', () => { + it('lists versions by object ID', async () => { + const versions = [{ id: 'v1', createdAt: '2024-01-01', currentVersion: true }]; + mockSdk.vault.listObjectVersions.mockResolvedValue(versions); + await runVaultListVersions('obj_123', 'sk_test'); + expect(mockSdk.vault.listObjectVersions).toHaveBeenCalledWith({ id: 'obj_123' }); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('list outputs { data, listMetadata }', async () => { + mockSdk.vault.listObjects.mockResolvedValue({ + data: [mockDigest], + listMetadata: { before: null, after: 'cursor_a' }, + }); + await runVaultList({}, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toHaveLength(1); + expect(output.listMetadata.after).toBe('cursor_a'); + }); + + it('get outputs raw JSON', async () => { + mockSdk.vault.readObject.mockResolvedValue(mockObject); + await runVaultGet('obj_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.id).toBe('obj_123'); + expect(output.value).toBe('secret-value'); + }); + + it('create outputs JSON success', async () => { + mockSdk.vault.createObject.mockResolvedValue(mockMetadata); + await runVaultCreate({ name: 'my-secret', value: 'val' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('obj_123'); + }); + + it('delete outputs JSON success', async () => { + mockSdk.vault.deleteObject.mockResolvedValue(undefined); + await runVaultDelete('obj_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('obj_123'); + }); + }); +}); diff --git a/src/commands/vault.ts b/src/commands/vault.ts new file mode 100644 index 0000000..19d18d8 --- /dev/null +++ b/src/commands/vault.ts @@ -0,0 +1,150 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Vault'); + +export interface VaultListOptions { + limit?: number; + before?: string; + after?: string; + order?: string; +} + +export async function runVaultList(options: VaultListOptions, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.vault.listObjects({ + limit: options.limit, + before: options.before, + after: options.after, + order: options.order as 'asc' | 'desc' | undefined, + }); + + if (isJsonMode()) { + outputJson({ data: result.data, listMetadata: result.listMetadata }); + return; + } + + if (result.data.length === 0) { + console.log('No vault objects found.'); + return; + } + + const rows = result.data.map((obj) => [obj.id, obj.name, obj.updatedAt ? String(obj.updatedAt) : chalk.dim('-')]); + + console.log(formatTable([{ header: 'ID' }, { header: 'Name' }, { header: 'Updated At' }], rows)); + + const { before, after } = result.listMetadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runVaultGet(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.vault.readObject({ id }); + outputJson(result); + } catch (error) { + handleApiError(error); + } +} + +export async function runVaultGetByName(name: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.vault.readObjectByName(name); + outputJson(result); + } catch (error) { + handleApiError(error); + } +} + +export interface VaultCreateOptions { + name: string; + value: string; + org?: string; +} + +export async function runVaultCreate(options: VaultCreateOptions, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const context = options.org ? { organizationId: options.org } : {}; + const result = await client.sdk.vault.createObject({ + name: options.name, + value: options.value, + context, + }); + outputSuccess('Created vault object', result); + } catch (error) { + handleApiError(error); + } +} + +export interface VaultUpdateOptions { + id: string; + value: string; + versionCheck?: string; +} + +export async function runVaultUpdate(options: VaultUpdateOptions, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.vault.updateObject({ + id: options.id, + value: options.value, + ...(options.versionCheck && { versionCheck: options.versionCheck }), + }); + outputSuccess('Updated vault object', result); + } catch (error) { + handleApiError(error); + } +} + +export async function runVaultDelete(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.sdk.vault.deleteObject({ id }); + outputSuccess('Deleted vault object', { id }); + } catch (error) { + handleApiError(error); + } +} + +export async function runVaultDescribe(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.vault.describeObject({ id }); + outputJson(result); + } catch (error) { + handleApiError(error); + } +} + +export async function runVaultListVersions(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.sdk.vault.listObjectVersions({ id }); + outputJson(result); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/commands/webhook.spec.ts b/src/commands/webhook.spec.ts new file mode 100644 index 0000000..1ed61a3 --- /dev/null +++ b/src/commands/webhook.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockClient = { + sdk: {}, + webhooks: { + list: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + }, +}; + +vi.mock('../lib/workos-client.js', () => ({ + createWorkOSClient: () => mockClient, +})); + +const { setOutputMode } = await import('../utils/output.js'); + +const { runWebhookList, runWebhookCreate, runWebhookDelete } = await import('./webhook.js'); + +const mockWebhook = { + id: 'we_123', + url: 'https://example.com/hook', + events: ['dsync.user.created'], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}; + +describe('webhook commands', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('runWebhookList', () => { + it('lists endpoints in table', async () => { + mockClient.webhooks.list.mockResolvedValue({ + data: [mockWebhook], + list_metadata: { before: null, after: null }, + }); + await runWebhookList('sk_test'); + expect(consoleOutput.some((l) => l.includes('we_123'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('https://example.com/hook'))).toBe(true); + }); + + it('handles empty results', async () => { + mockClient.webhooks.list.mockResolvedValue({ + data: [], + list_metadata: { before: null, after: null }, + }); + await runWebhookList('sk_test'); + expect(consoleOutput.some((l) => l.includes('No webhook endpoints found'))).toBe(true); + }); + }); + + describe('runWebhookCreate', () => { + it('creates webhook with url and events', async () => { + mockClient.webhooks.create.mockResolvedValue(mockWebhook); + await runWebhookCreate('https://example.com/hook', ['dsync.user.created'], 'sk_test'); + expect(mockClient.webhooks.create).toHaveBeenCalledWith('https://example.com/hook', ['dsync.user.created']); + }); + + it('displays secret warning in human mode', async () => { + mockClient.webhooks.create.mockResolvedValue({ ...mockWebhook, secret: 'whsec_abc123' }); + await runWebhookCreate('https://example.com/hook', ['dsync.user.created'], 'sk_test'); + expect(consoleOutput.some((l) => l.includes('Created webhook endpoint'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('whsec_abc123'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('not be shown again'))).toBe(true); + }); + }); + + describe('runWebhookDelete', () => { + it('deletes webhook by ID', async () => { + mockClient.webhooks.delete.mockResolvedValue(undefined); + await runWebhookDelete('we_123', 'sk_test'); + expect(mockClient.webhooks.delete).toHaveBeenCalledWith('we_123'); + expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + }); + }); + + describe('JSON output mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('list normalizes list_metadata to listMetadata', async () => { + mockClient.webhooks.list.mockResolvedValue({ + data: [mockWebhook], + list_metadata: { before: null, after: 'cursor_a' }, + }); + await runWebhookList('sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.listMetadata).toBeDefined(); + expect(output.listMetadata.after).toBe('cursor_a'); + expect(output).not.toHaveProperty('list_metadata'); + }); + + it('list outputs empty data for no results', async () => { + mockClient.webhooks.list.mockResolvedValue({ + data: [], + list_metadata: { before: null, after: null }, + }); + await runWebhookList('sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.data).toEqual([]); + }); + + it('create includes secret in JSON output', async () => { + mockClient.webhooks.create.mockResolvedValue({ ...mockWebhook, secret: 'whsec_abc123' }); + await runWebhookCreate('https://example.com/hook', ['dsync.user.created'], 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.secret).toBe('whsec_abc123'); + }); + + it('delete outputs JSON success', async () => { + mockClient.webhooks.delete.mockResolvedValue(undefined); + await runWebhookDelete('we_123', 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('we_123'); + }); + }); +}); diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts new file mode 100644 index 0000000..2ed701f --- /dev/null +++ b/src/commands/webhook.ts @@ -0,0 +1,81 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { formatTable } from '../utils/table.js'; +import { outputJson, outputSuccess, isJsonMode } from '../utils/output.js'; +import { createApiErrorHandler } from '../lib/api-error-handler.js'; + +const handleApiError = createApiErrorHandler('Webhook'); + +export async function runWebhookList(apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const result = await client.webhooks.list(); + + if (isJsonMode()) { + // Normalize snake_case list_metadata to camelCase for consistent CLI output + outputJson({ + data: result.data, + listMetadata: { + before: result.list_metadata.before, + after: result.list_metadata.after, + }, + }); + return; + } + + if (result.data.length === 0) { + console.log('No webhook endpoints found.'); + return; + } + + const rows = result.data.map((ep) => [ep.id, ep.url, ep.events.join(', '), ep.created_at]); + + console.log(formatTable([{ header: 'ID' }, { header: 'URL' }, { header: 'Events' }, { header: 'Created' }], rows)); + + const { before, after } = result.list_metadata; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runWebhookCreate(url: string, events: string[], apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const endpoint = await client.webhooks.create(url, events); + + if (isJsonMode()) { + outputJson({ status: 'ok', message: 'Created webhook endpoint', data: endpoint }); + return; + } + + console.log(chalk.green('Created webhook endpoint')); + console.log(JSON.stringify(endpoint, null, 2)); + if (endpoint.secret) { + console.log(''); + console.log(chalk.yellow('Signing secret: ') + endpoint.secret); + console.log(chalk.yellow('Save this secret now — it will not be shown again.')); + } + } catch (error) { + handleApiError(error); + } +} + +export async function runWebhookDelete(id: string, apiKey: string, baseUrl?: string): Promise { + const client = createWorkOSClient(apiKey, baseUrl); + + try { + await client.webhooks.delete(id); + outputSuccess('Deleted webhook endpoint', { id }); + } catch (error) { + handleApiError(error); + } +} diff --git a/src/lib/api-error-handler.spec.ts b/src/lib/api-error-handler.spec.ts new file mode 100644 index 0000000..933f4c4 --- /dev/null +++ b/src/lib/api-error-handler.spec.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { WorkOSApiError } from './workos-api.js'; +import { createApiErrorHandler } from './api-error-handler.js'; +import { setOutputMode } from '../utils/output.js'; + +describe('createApiErrorHandler', () => { + let stderrOutput: string[]; + let exitCode: number | undefined; + + beforeEach(() => { + setOutputMode('json'); + stderrOutput = []; + exitCode = undefined; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + stderrOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(process, 'exit').mockImplementation((code?: number) => { + exitCode = code ?? 0; + return undefined as never; + }); + }); + + afterEach(() => { + setOutputMode('human'); + vi.restoreAllMocks(); + }); + + function parseError(): { error: { code: string; message: string; details?: unknown } } { + return JSON.parse(stderrOutput[0]); + } + + describe('WorkOSApiError (raw fetch)', () => { + it('handles 401 with friendly message', () => { + const handler = createApiErrorHandler('Organization'); + handler(new WorkOSApiError('Unauthorized', 401)); + expect(parseError().error.message).toBe('Invalid API key. Check your environment configuration.'); + expect(parseError().error.code).toBe('http_401'); + expect(exitCode).toBe(1); + }); + + it('handles 404 with resource name', () => { + const handler = createApiErrorHandler('Organization'); + handler(new WorkOSApiError('Not Found', 404)); + expect(parseError().error.message).toBe('Organization not found.'); + }); + + it('handles 422 with validation errors', () => { + const handler = createApiErrorHandler('Organization'); + handler( + new WorkOSApiError('Validation failed', 422, undefined, [ + { message: 'Name is required' }, + { message: 'Domain invalid' }, + ]), + ); + expect(parseError().error.message).toBe('Name is required, Domain invalid'); + }); + + it('uses error.code when available', () => { + const handler = createApiErrorHandler('User'); + handler(new WorkOSApiError('Bad request', 400, 'invalid_request')); + expect(parseError().error.code).toBe('invalid_request'); + }); + + it('falls back to http_{status} code', () => { + const handler = createApiErrorHandler('User'); + handler(new WorkOSApiError('Server error', 500)); + expect(parseError().error.code).toBe('http_500'); + }); + }); + + describe('SDK exceptions (@workos-inc/node)', () => { + function makeSdkError( + status: number, + message: string, + extras?: { code?: string; requestID?: string; errors?: Array<{ message: string }> }, + ) { + const err = new Error(message) as Error & { + status: number; + requestID: string; + code?: string; + errors?: Array<{ message: string }>; + }; + err.status = status; + err.requestID = extras?.requestID ?? 'req_test'; + if (extras?.code) err.code = extras.code; + if (extras?.errors) err.errors = extras.errors; + return err; + } + + it('handles 401 (UnauthorizedException)', () => { + const handler = createApiErrorHandler('Organization'); + handler(makeSdkError(401, 'Could not authorize the request')); + expect(parseError().error.message).toBe('Invalid API key. Check your environment configuration.'); + expect(parseError().error.code).toBe('http_401'); + }); + + it('handles 404 (NotFoundException)', () => { + const handler = createApiErrorHandler('Role'); + handler(makeSdkError(404, 'Resource not found')); + expect(parseError().error.message).toBe('Role not found.'); + }); + + it('handles 422 with errors array', () => { + const handler = createApiErrorHandler('Permission'); + handler(makeSdkError(422, 'Validation failed', { errors: [{ message: 'Slug already taken' }] })); + expect(parseError().error.message).toBe('Slug already taken'); + }); + + it('handles 400 (BadRequestException) with raw message', () => { + const handler = createApiErrorHandler('Event'); + handler(makeSdkError(400, 'events parameter is required')); + expect(parseError().error.message).toBe('events parameter is required'); + }); + + it('handles 429 (RateLimitExceededException)', () => { + const handler = createApiErrorHandler('User'); + handler(makeSdkError(429, 'Rate limit exceeded')); + expect(parseError().error.message).toBe('Rate limit exceeded'); + expect(parseError().error.code).toBe('http_429'); + }); + + it('handles 500 (GenericServerException)', () => { + const handler = createApiErrorHandler('Webhook'); + handler(makeSdkError(500, 'Internal server error')); + expect(parseError().error.message).toBe('Internal server error'); + }); + + it('uses code when available', () => { + const handler = createApiErrorHandler('User'); + handler(makeSdkError(422, 'Invalid', { code: 'validation_error' })); + expect(parseError().error.code).toBe('validation_error'); + }); + }); + + describe('fallback (generic errors)', () => { + it('handles generic Error', () => { + const handler = createApiErrorHandler('Thing'); + handler(new Error('Network timeout')); + expect(parseError().error.code).toBe('unknown_error'); + expect(parseError().error.message).toBe('Network timeout'); + }); + + it('handles non-Error values', () => { + const handler = createApiErrorHandler('Thing'); + handler('some string'); + expect(parseError().error.code).toBe('unknown_error'); + expect(parseError().error.message).toBe('Unknown error'); + }); + + it('handles null', () => { + const handler = createApiErrorHandler('Thing'); + handler(null); + expect(parseError().error.code).toBe('unknown_error'); + }); + }); +}); diff --git a/src/lib/api-error-handler.ts b/src/lib/api-error-handler.ts index bff4c26..4940e72 100644 --- a/src/lib/api-error-handler.ts +++ b/src/lib/api-error-handler.ts @@ -1,12 +1,29 @@ import { WorkOSApiError } from './workos-api.js'; import { exitWithError } from '../utils/output.js'; +/** + * Duck-type check for @workos-inc/node SDK exceptions. + * + * The SDK throws typed errors (UnauthorizedException, NotFoundException, etc.) + * that implement the RequestException interface: { status, message, requestID }. + * We duck-type rather than instanceof to avoid coupling to the SDK's class hierarchy. + */ +function isSdkException( + error: unknown, +): error is { status: number; message: string; requestID: string; code?: string; errors?: Array<{ message: string }> } { + if (!(error instanceof Error)) return false; + const e = error as Error & { status?: unknown; requestID?: unknown }; + return typeof e.status === 'number' && typeof e.requestID === 'string'; +} + /** * Create a resource-specific API error handler. + * Handles both raw fetch errors (WorkOSApiError) and SDK exceptions. * Returns a `never` function that writes structured errors and exits. */ export function createApiErrorHandler(resourceName: string) { return (error: unknown): never => { + // 1. Raw fetch errors (workos-api.ts) if (error instanceof WorkOSApiError) { exitWithError({ code: error.code ?? `http_${error.statusCode}`, @@ -21,6 +38,24 @@ export function createApiErrorHandler(resourceName: string) { details: error.errors, }); } + + // 2. SDK exceptions (@workos-inc/node) + if (isSdkException(error)) { + exitWithError({ + code: error.code ?? `http_${error.status}`, + message: + error.status === 401 + ? 'Invalid API key. Check your environment configuration.' + : error.status === 404 + ? `${resourceName} not found.` + : error.status === 422 && error.errors?.length + ? error.errors.map((e) => e.message).join(', ') + : error.message, + details: error.errors, + }); + } + + // 3. Fallback exitWithError({ code: 'unknown_error', message: error instanceof Error ? error.message : 'Unknown error', diff --git a/src/lib/workos-client.spec.ts b/src/lib/workos-client.spec.ts new file mode 100644 index 0000000..390ca7c --- /dev/null +++ b/src/lib/workos-client.spec.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock workos-api before any imports that use it +vi.mock('./workos-api.js', () => ({ + workosRequest: vi.fn(), + WorkOSApiError: class WorkOSApiError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly code?: string, + public readonly errors?: Array<{ message: string }>, + ) { + super(message); + this.name = 'WorkOSApiError'; + } + }, +})); + +// Mock api-key to avoid config-store dependency +vi.mock('./api-key.js', () => ({ + resolveApiKey: () => 'sk_test_default', + resolveApiBaseUrl: () => 'https://api.workos.com', +})); + +const { workosRequest, WorkOSApiError } = await import('./workos-api.js'); +const mockRequest = vi.mocked(workosRequest); + +const { createWorkOSClient } = await import('./workos-client.js'); + +describe('workos-client', () => { + beforeEach(() => { + mockRequest.mockReset(); + }); + + describe('createWorkOSClient', () => { + it('creates client with explicit apiKey and baseUrl', () => { + const client = createWorkOSClient('sk_test_123', 'https://custom.api.com'); + expect(client.sdk).toBeDefined(); + expect(client.sdk.key).toBe('sk_test_123'); + expect(client.sdk.baseURL).toBe('https://custom.api.com'); + }); + + it('falls back to resolveApiKey/resolveApiBaseUrl when no args', () => { + const client = createWorkOSClient(); + expect(client.sdk.key).toBe('sk_test_default'); + expect(client.sdk.baseURL).toBe('https://api.workos.com'); + }); + + it('exposes sdk, webhooks, redirectUris, corsOrigins, homepageUrl', () => { + const client = createWorkOSClient('sk_test_123'); + expect(client.sdk).toBeDefined(); + expect(client.webhooks).toBeDefined(); + expect(client.redirectUris).toBeDefined(); + expect(client.corsOrigins).toBeDefined(); + expect(client.homepageUrl).toBeDefined(); + }); + }); + + describe('webhooks', () => { + it('list calls correct path', async () => { + const mockData = { data: [], list_metadata: { before: null, after: null } }; + mockRequest.mockResolvedValue(mockData); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + const result = await client.webhooks.list(); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/webhook_endpoints', + apiKey: 'sk_test_123', + baseUrl: 'https://api.workos.com', + }), + ); + expect(result).toBe(mockData); + }); + + it('create calls correct path with body', async () => { + const mockEndpoint = { id: 'we_123', url: 'https://example.com/hook', events: ['user.created'] }; + mockRequest.mockResolvedValue(mockEndpoint); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + const result = await client.webhooks.create('https://example.com/hook', ['user.created']); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/webhook_endpoints', + body: { endpoint_url: 'https://example.com/hook', events: ['user.created'] }, + }), + ); + expect(result).toBe(mockEndpoint); + }); + + it('delete calls correct path', async () => { + mockRequest.mockResolvedValue(null); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + await client.webhooks.delete('we_123'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + path: '/webhook_endpoints/we_123', + }), + ); + }); + }); + + describe('redirectUris', () => { + it('add returns success on 201', async () => { + mockRequest.mockResolvedValue({ id: 'ru_123' }); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + const result = await client.redirectUris.add('http://localhost:3000/callback'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/user_management/redirect_uris', + body: { uri: 'http://localhost:3000/callback' }, + }), + ); + expect(result).toEqual({ success: true, alreadyExists: false }); + }); + + it('add treats 422 "already exists" as success', async () => { + mockRequest.mockRejectedValue(new WorkOSApiError('URI already exists', 422)); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + const result = await client.redirectUris.add('http://localhost:3000/callback'); + + expect(result).toEqual({ success: true, alreadyExists: true }); + }); + + it('add treats 409 as success', async () => { + mockRequest.mockRejectedValue(new WorkOSApiError('Conflict', 409)); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + const result = await client.redirectUris.add('http://localhost:3000/callback'); + + expect(result).toEqual({ success: true, alreadyExists: true }); + }); + + it('add rethrows other errors', async () => { + mockRequest.mockRejectedValue(new WorkOSApiError('Unauthorized', 401)); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + await expect(client.redirectUris.add('http://localhost:3000/callback')).rejects.toThrow('Unauthorized'); + }); + }); + + describe('corsOrigins', () => { + it('add returns success on 201', async () => { + mockRequest.mockResolvedValue({ id: 'co_123' }); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + const result = await client.corsOrigins.add('http://localhost:3000'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/user_management/cors_origins', + body: { origin: 'http://localhost:3000' }, + }), + ); + expect(result).toEqual({ success: true, alreadyExists: false }); + }); + + it('add treats 422 "already exists" as success', async () => { + mockRequest.mockRejectedValue(new WorkOSApiError('Origin already exists', 422)); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + const result = await client.corsOrigins.add('http://localhost:3000'); + + expect(result).toEqual({ success: true, alreadyExists: true }); + }); + + it('add rethrows other errors', async () => { + mockRequest.mockRejectedValue(new WorkOSApiError('Server Error', 500)); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + await expect(client.corsOrigins.add('http://localhost:3000')).rejects.toThrow('Server Error'); + }); + }); + + describe('homepageUrl', () => { + it('set calls correct path with body', async () => { + mockRequest.mockResolvedValue(null); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + await client.homepageUrl.set('http://localhost:3000'); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PUT', + path: '/user_management/app_homepage_url', + body: { url: 'http://localhost:3000' }, + }), + ); + }); + }); +}); diff --git a/src/lib/workos-client.ts b/src/lib/workos-client.ts new file mode 100644 index 0000000..b500f80 --- /dev/null +++ b/src/lib/workos-client.ts @@ -0,0 +1,183 @@ +/** + * Unified WorkOS client for CLI commands. + * + * Wraps @workos-inc/node SDK for documented endpoints and extends with + * raw-fetch methods for undocumented/write-only endpoints (webhooks, redirect URIs, etc.). + * Commands import one client; they don't care whether a method is SDK-backed or raw fetch. + */ + +import { WorkOS } from '@workos-inc/node'; +import { workosRequest, type WorkOSListResponse } from './workos-api.js'; +import { resolveApiKey, resolveApiBaseUrl } from './api-key.js'; + +export interface WebhookEndpoint { + id: string; + url: string; + events: string[]; + secret?: string; + created_at: string; + updated_at: string; +} + +export interface AuditLogAction { + action: string; +} + +export interface AuditLogRetention { + retention_period_in_days: number; +} + +export interface WorkOSCLIClient { + sdk: WorkOS; + webhooks: { + list(): Promise>; + create(endpointUrl: string, events: string[]): Promise; + delete(id: string): Promise; + }; + redirectUris: { + add(uri: string): Promise<{ success: boolean; alreadyExists: boolean }>; + }; + corsOrigins: { + add(origin: string): Promise<{ success: boolean; alreadyExists: boolean }>; + }; + homepageUrl: { + set(url: string): Promise; + }; + auditLogs: { + listActions(): Promise>; + getSchema(action: string): Promise; + getRetention(orgId: string): Promise; + }; +} + +/** + * Create a unified WorkOS client. + * + * @param apiKey - Explicit API key; falls back to resolveApiKey() + * @param baseUrl - Explicit base URL; falls back to resolveApiBaseUrl() + */ +export function createWorkOSClient(apiKey?: string, baseUrl?: string): WorkOSCLIClient { + const key = apiKey ?? resolveApiKey(); + const base = baseUrl ?? resolveApiBaseUrl(); + + // Parse hostname from base URL for SDK init + const hostname = new URL(base).hostname; + const sdk = new WorkOS(key, { apiHostname: hostname }); + + return { + sdk, + + webhooks: { + async list() { + return workosRequest>({ + method: 'GET', + path: '/webhook_endpoints', + apiKey: key, + baseUrl: base, + }); + }, + async create(endpointUrl: string, events: string[]) { + return workosRequest({ + method: 'POST', + path: '/webhook_endpoints', + apiKey: key, + baseUrl: base, + body: { endpoint_url: endpointUrl, events }, + }); + }, + async delete(id: string) { + await workosRequest({ + method: 'DELETE', + path: `/webhook_endpoints/${id}`, + apiKey: key, + baseUrl: base, + }); + }, + }, + + redirectUris: { + async add(uri: string) { + try { + await workosRequest({ + method: 'POST', + path: '/user_management/redirect_uris', + apiKey: key, + baseUrl: base, + body: { uri }, + }); + return { success: true, alreadyExists: false }; + } catch (error: unknown) { + const { WorkOSApiError } = await import('./workos-api.js'); + if (error instanceof WorkOSApiError) { + if (error.statusCode === 409 || (error.statusCode === 422 && error.message.includes('already exists'))) { + return { success: true, alreadyExists: true }; + } + } + throw error; + } + }, + }, + + corsOrigins: { + async add(origin: string) { + try { + await workosRequest({ + method: 'POST', + path: '/user_management/cors_origins', + apiKey: key, + baseUrl: base, + body: { origin }, + }); + return { success: true, alreadyExists: false }; + } catch (error: unknown) { + const { WorkOSApiError } = await import('./workos-api.js'); + if (error instanceof WorkOSApiError) { + if (error.statusCode === 409 || (error.statusCode === 422 && error.message.includes('already exists'))) { + return { success: true, alreadyExists: true }; + } + } + throw error; + } + }, + }, + + homepageUrl: { + async set(url: string) { + await workosRequest({ + method: 'PUT', + path: '/user_management/app_homepage_url', + apiKey: key, + baseUrl: base, + body: { url }, + }); + }, + }, + + auditLogs: { + async listActions() { + return workosRequest>({ + method: 'GET', + path: '/audit_logs/actions', + apiKey: key, + baseUrl: base, + }); + }, + async getSchema(action: string) { + return workosRequest({ + method: 'GET', + path: `/audit_logs/actions/${encodeURIComponent(action)}/schemas`, + apiKey: key, + baseUrl: base, + }); + }, + async getRetention(orgId: string) { + return workosRequest({ + method: 'GET', + path: `/organizations/${encodeURIComponent(orgId)}/audit_logs_retention`, + apiKey: key, + baseUrl: base, + }); + }, + }, + }; +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 341d619..6d46bc4 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -348,6 +348,713 @@ const commands: CommandSchema[] = [ }, ], }, + // --- Resource Management Commands --- + { + name: 'role', + description: 'Manage WorkOS roles (environment and organization-scoped)', + options: [ + insecureStorageOpt, + apiKeyOpt, + { + name: 'org', + type: 'string', + description: 'Organization ID (for org-scoped roles)', + required: false, + hidden: false, + }, + ], + commands: [ + { name: 'list', description: 'List roles', options: [] }, + { + name: 'get', + description: 'Get a role by slug', + positionals: [{ name: 'slug', type: 'string', description: 'Role slug', required: true }], + }, + { + name: 'create', + description: 'Create a role', + options: [ + { name: 'slug', type: 'string', description: 'Role slug', required: true, hidden: false }, + { name: 'name', type: 'string', description: 'Role name', required: true, hidden: false }, + { name: 'description', type: 'string', description: 'Role description', required: false, hidden: false }, + ], + }, + { + name: 'update', + description: 'Update a role', + positionals: [{ name: 'slug', type: 'string', description: 'Role slug', required: true }], + options: [ + { name: 'name', type: 'string', description: 'New name', required: false, hidden: false }, + { name: 'description', type: 'string', description: 'New description', required: false, hidden: false }, + ], + }, + { + name: 'delete', + description: 'Delete an org-scoped role (requires --org)', + positionals: [{ name: 'slug', type: 'string', description: 'Role slug', required: true }], + }, + { + name: 'set-permissions', + description: 'Set all permissions on a role (replaces existing)', + positionals: [{ name: 'slug', type: 'string', description: 'Role slug', required: true }], + options: [ + { + name: 'permissions', + type: 'string', + description: 'Comma-separated permission slugs', + required: true, + hidden: false, + }, + ], + }, + { + name: 'add-permission', + description: 'Add a permission to a role', + positionals: [ + { name: 'slug', type: 'string', description: 'Role slug', required: true }, + { name: 'permissionSlug', type: 'string', description: 'Permission slug', required: true }, + ], + }, + { + name: 'remove-permission', + description: 'Remove a permission from an org role (requires --org)', + positionals: [ + { name: 'slug', type: 'string', description: 'Role slug', required: true }, + { name: 'permissionSlug', type: 'string', description: 'Permission slug', required: true }, + ], + }, + ], + }, + { + name: 'permission', + description: 'Manage WorkOS permissions', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { name: 'list', description: 'List permissions', options: [...paginationOpts] }, + { + name: 'get', + description: 'Get a permission', + positionals: [{ name: 'slug', type: 'string', description: 'Permission slug', required: true }], + }, + { + name: 'create', + description: 'Create a permission', + options: [ + { name: 'slug', type: 'string', description: 'Permission slug', required: true, hidden: false }, + { name: 'name', type: 'string', description: 'Permission name', required: true, hidden: false }, + { + name: 'description', + type: 'string', + description: 'Permission description', + required: false, + hidden: false, + }, + ], + }, + { + name: 'update', + description: 'Update a permission', + positionals: [{ name: 'slug', type: 'string', description: 'Permission slug', required: true }], + options: [ + { name: 'name', type: 'string', description: 'New name', required: false, hidden: false }, + { name: 'description', type: 'string', description: 'New description', required: false, hidden: false }, + ], + }, + { + name: 'delete', + description: 'Delete a permission', + positionals: [{ name: 'slug', type: 'string', description: 'Permission slug', required: true }], + }, + ], + }, + { + name: 'membership', + description: 'Manage organization memberships', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'list', + description: 'List memberships', + options: [ + { name: 'org', type: 'string', description: 'Filter by organization ID', required: false, hidden: false }, + { name: 'user', type: 'string', description: 'Filter by user ID', required: false, hidden: false }, + ...paginationOpts, + ], + }, + { + name: 'get', + description: 'Get a membership', + positionals: [{ name: 'id', type: 'string', description: 'Membership ID', required: true }], + }, + { + name: 'create', + description: 'Create a membership', + options: [ + { name: 'org', type: 'string', description: 'Organization ID', required: true, hidden: false }, + { name: 'user', type: 'string', description: 'User ID', required: true, hidden: false }, + { name: 'role', type: 'string', description: 'Role slug', required: false, hidden: false }, + ], + }, + { + name: 'update', + description: 'Update a membership', + positionals: [{ name: 'id', type: 'string', description: 'Membership ID', required: true }], + options: [{ name: 'role', type: 'string', description: 'New role slug', required: false, hidden: false }], + }, + { + name: 'delete', + description: 'Delete a membership', + positionals: [{ name: 'id', type: 'string', description: 'Membership ID', required: true }], + }, + { + name: 'deactivate', + description: 'Deactivate a membership', + positionals: [{ name: 'id', type: 'string', description: 'Membership ID', required: true }], + }, + { + name: 'reactivate', + description: 'Reactivate a membership', + positionals: [{ name: 'id', type: 'string', description: 'Membership ID', required: true }], + }, + ], + }, + { + name: 'invitation', + description: 'Manage user invitations', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'list', + description: 'List invitations', + options: [ + { name: 'org', type: 'string', description: 'Filter by organization ID', required: false, hidden: false }, + { name: 'email', type: 'string', description: 'Filter by email', required: false, hidden: false }, + ...paginationOpts, + ], + }, + { + name: 'get', + description: 'Get an invitation', + positionals: [{ name: 'id', type: 'string', description: 'Invitation ID', required: true }], + }, + { + name: 'send', + description: 'Send an invitation', + options: [ + { name: 'email', type: 'string', description: 'Email address', required: true, hidden: false }, + { name: 'org', type: 'string', description: 'Organization ID', required: false, hidden: false }, + { name: 'role', type: 'string', description: 'Role slug', required: false, hidden: false }, + { + name: 'expires-in-days', + type: 'number', + description: 'Expiration in days', + required: false, + hidden: false, + }, + ], + }, + { + name: 'revoke', + description: 'Revoke an invitation', + positionals: [{ name: 'id', type: 'string', description: 'Invitation ID', required: true }], + }, + { + name: 'resend', + description: 'Resend an invitation', + positionals: [{ name: 'id', type: 'string', description: 'Invitation ID', required: true }], + }, + ], + }, + { + name: 'session', + description: 'Manage user sessions', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'list', + description: 'List sessions for a user', + positionals: [{ name: 'userId', type: 'string', description: 'User ID', required: true }], + options: [...paginationOpts], + }, + { + name: 'revoke', + description: 'Revoke a session', + positionals: [{ name: 'sessionId', type: 'string', description: 'Session ID', required: true }], + }, + ], + }, + { + name: 'connection', + description: 'Manage SSO connections (read/delete)', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'list', + description: 'List connections', + options: [ + { name: 'org', type: 'string', description: 'Filter by organization ID', required: false, hidden: false }, + { name: 'type', type: 'string', description: 'Filter by connection type', required: false, hidden: false }, + ...paginationOpts, + ], + }, + { + name: 'get', + description: 'Get a connection', + positionals: [{ name: 'id', type: 'string', description: 'Connection ID', required: true }], + }, + { + name: 'delete', + description: 'Delete a connection', + positionals: [{ name: 'id', type: 'string', description: 'Connection ID', required: true }], + options: [ + { + name: 'force', + type: 'boolean', + description: 'Skip confirmation prompt', + required: false, + default: false, + hidden: false, + }, + ], + }, + ], + }, + { + name: 'directory', + description: 'Manage directory sync (read/delete, list users/groups)', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'list', + description: 'List directories', + options: [ + { name: 'org', type: 'string', description: 'Filter by organization ID', required: false, hidden: false }, + ...paginationOpts, + ], + }, + { + name: 'get', + description: 'Get a directory', + positionals: [{ name: 'id', type: 'string', description: 'Directory ID', required: true }], + }, + { + name: 'delete', + description: 'Delete a directory', + positionals: [{ name: 'id', type: 'string', description: 'Directory ID', required: true }], + options: [ + { + name: 'force', + type: 'boolean', + description: 'Skip confirmation prompt', + required: false, + default: false, + hidden: false, + }, + ], + }, + { + name: 'list-users', + description: 'List directory users', + options: [ + { name: 'directory', type: 'string', description: 'Directory ID', required: false, hidden: false }, + { name: 'group', type: 'string', description: 'Group ID', required: false, hidden: false }, + ...paginationOpts, + ], + }, + { + name: 'list-groups', + description: 'List directory groups', + options: [ + { name: 'directory', type: 'string', description: 'Directory ID', required: true, hidden: false }, + ...paginationOpts, + ], + }, + ], + }, + { + name: 'event', + description: 'Query WorkOS events', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'list', + description: 'List events', + options: [ + { + name: 'events', + type: 'string', + description: 'Comma-separated event types (required)', + required: true, + hidden: false, + }, + { name: 'org', type: 'string', description: 'Filter by organization ID', required: false, hidden: false }, + { + name: 'range-start', + type: 'string', + description: 'Range start (ISO date)', + required: false, + hidden: false, + }, + { name: 'range-end', type: 'string', description: 'Range end (ISO date)', required: false, hidden: false }, + ...paginationOpts, + ], + }, + ], + }, + { + name: 'audit-log', + description: 'Manage audit logs', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'create-event', + description: 'Create an audit log event', + positionals: [{ name: 'orgId', type: 'string', description: 'Organization ID', required: true }], + options: [ + { name: 'action', type: 'string', description: 'Action name', required: false, hidden: false }, + { name: 'actor-type', type: 'string', description: 'Actor type', required: false, hidden: false }, + { name: 'actor-id', type: 'string', description: 'Actor ID', required: false, hidden: false }, + { name: 'file', type: 'string', description: 'Path to event JSON file', required: false, hidden: false }, + ], + }, + { + name: 'export', + description: 'Export audit logs', + options: [ + { name: 'org', type: 'string', description: 'Organization ID', required: true, hidden: false }, + { name: 'range-start', type: 'string', description: 'Range start (ISO date)', required: true, hidden: false }, + { name: 'range-end', type: 'string', description: 'Range end (ISO date)', required: true, hidden: false }, + ], + }, + { name: 'list-actions', description: 'List available audit log actions' }, + { + name: 'get-schema', + description: 'Get schema for an audit log action', + positionals: [{ name: 'action', type: 'string', description: 'Action name', required: true }], + }, + { + name: 'create-schema', + description: 'Create an audit log schema', + positionals: [{ name: 'action', type: 'string', description: 'Action name', required: true }], + options: [ + { name: 'file', type: 'string', description: 'Path to schema JSON file', required: true, hidden: false }, + ], + }, + { + name: 'get-retention', + description: 'Get audit log retention period', + positionals: [{ name: 'orgId', type: 'string', description: 'Organization ID', required: true }], + }, + ], + }, + { + name: 'feature-flag', + description: 'Manage feature flags', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { name: 'list', description: 'List feature flags', options: [...paginationOpts] }, + { + name: 'get', + description: 'Get a feature flag', + positionals: [{ name: 'slug', type: 'string', description: 'Feature flag slug', required: true }], + }, + { + name: 'enable', + description: 'Enable a feature flag', + positionals: [{ name: 'slug', type: 'string', description: 'Feature flag slug', required: true }], + }, + { + name: 'disable', + description: 'Disable a feature flag', + positionals: [{ name: 'slug', type: 'string', description: 'Feature flag slug', required: true }], + }, + { + name: 'add-target', + description: 'Add a target to a feature flag', + positionals: [ + { name: 'slug', type: 'string', description: 'Feature flag slug', required: true }, + { name: 'targetId', type: 'string', description: 'Target ID', required: true }, + ], + }, + { + name: 'remove-target', + description: 'Remove a target from a feature flag', + positionals: [ + { name: 'slug', type: 'string', description: 'Feature flag slug', required: true }, + { name: 'targetId', type: 'string', description: 'Target ID', required: true }, + ], + }, + ], + }, + { + name: 'webhook', + description: 'Manage webhooks', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { name: 'list', description: 'List webhooks' }, + { + name: 'create', + description: 'Create a webhook', + options: [ + { name: 'url', type: 'string', description: 'Webhook endpoint URL', required: true, hidden: false }, + { name: 'events', type: 'string', description: 'Comma-separated event types', required: true, hidden: false }, + ], + }, + { + name: 'delete', + description: 'Delete a webhook', + positionals: [{ name: 'id', type: 'string', description: 'Webhook ID', required: true }], + }, + ], + }, + { + name: 'config', + description: 'Manage WorkOS configuration (redirect URIs, CORS, homepage)', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'redirect', + description: 'Manage redirect URIs', + commands: [ + { + name: 'add', + description: 'Add a redirect URI', + positionals: [{ name: 'uri', type: 'string', description: 'Redirect URI', required: true }], + }, + ], + }, + { + name: 'cors', + description: 'Manage CORS origins', + commands: [ + { + name: 'add', + description: 'Add a CORS origin', + positionals: [{ name: 'origin', type: 'string', description: 'CORS origin', required: true }], + }, + ], + }, + { + name: 'homepage-url', + description: 'Manage homepage URL', + commands: [ + { + name: 'set', + description: 'Set the homepage URL', + positionals: [{ name: 'url', type: 'string', description: 'Homepage URL', required: true }], + }, + ], + }, + ], + }, + { + name: 'portal', + description: 'Manage Admin Portal', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'generate-link', + description: 'Generate an Admin Portal link', + options: [ + { + name: 'intent', + type: 'string', + description: 'Portal intent (sso, dsync, audit_logs, log_streams)', + required: true, + hidden: false, + }, + { name: 'org', type: 'string', description: 'Organization ID', required: true, hidden: false }, + { + name: 'return-url', + type: 'string', + description: 'Return URL after portal', + required: false, + hidden: false, + }, + { name: 'success-url', type: 'string', description: 'Success URL', required: false, hidden: false }, + ], + }, + ], + }, + { + name: 'vault', + description: 'Manage WorkOS Vault secrets', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { name: 'list', description: 'List vault objects', options: [...paginationOpts] }, + { + name: 'get', + description: 'Get a vault object', + positionals: [{ name: 'id', type: 'string', description: 'Object ID', required: true }], + }, + { + name: 'get-by-name', + description: 'Get a vault object by name', + positionals: [{ name: 'name', type: 'string', description: 'Object name', required: true }], + }, + { + name: 'create', + description: 'Create a vault object', + options: [ + { name: 'name', type: 'string', description: 'Object name', required: true, hidden: false }, + { name: 'value', type: 'string', description: 'Secret value', required: true, hidden: false }, + { name: 'org', type: 'string', description: 'Organization ID', required: false, hidden: false }, + ], + }, + { + name: 'update', + description: 'Update a vault object', + positionals: [{ name: 'id', type: 'string', description: 'Object ID', required: true }], + options: [ + { name: 'value', type: 'string', description: 'New value', required: true, hidden: false }, + { name: 'version-check', type: 'string', description: 'Version check ID', required: false, hidden: false }, + ], + }, + { + name: 'delete', + description: 'Delete a vault object', + positionals: [{ name: 'id', type: 'string', description: 'Object ID', required: true }], + }, + { + name: 'describe', + description: 'Describe a vault object', + positionals: [{ name: 'id', type: 'string', description: 'Object ID', required: true }], + }, + { + name: 'list-versions', + description: 'List vault object versions', + positionals: [{ name: 'id', type: 'string', description: 'Object ID', required: true }], + }, + ], + }, + { + name: 'api-key', + description: 'Manage API keys', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'list', + description: 'List API keys', + options: [ + { name: 'org', type: 'string', description: 'Organization ID', required: true, hidden: false }, + ...paginationOpts, + ], + }, + { + name: 'create', + description: 'Create an API key', + options: [ + { name: 'org', type: 'string', description: 'Organization ID', required: true, hidden: false }, + { name: 'name', type: 'string', description: 'Key name', required: true, hidden: false }, + { + name: 'permissions', + type: 'string', + description: 'Comma-separated permissions', + required: false, + hidden: false, + }, + ], + }, + { + name: 'validate', + description: 'Validate an API key', + positionals: [{ name: 'value', type: 'string', description: 'API key value', required: true }], + }, + { + name: 'delete', + description: 'Delete an API key', + positionals: [{ name: 'id', type: 'string', description: 'API key ID', required: true }], + }, + ], + }, + { + name: 'org-domain', + description: 'Manage organization domains', + options: [insecureStorageOpt, apiKeyOpt], + commands: [ + { + name: 'get', + description: 'Get a domain', + positionals: [{ name: 'id', type: 'string', description: 'Domain ID', required: true }], + }, + { + name: 'create', + description: 'Create a domain', + positionals: [{ name: 'domain', type: 'string', description: 'Domain name', required: true }], + options: [{ name: 'org', type: 'string', description: 'Organization ID', required: true, hidden: false }], + }, + { + name: 'verify', + description: 'Verify a domain', + positionals: [{ name: 'id', type: 'string', description: 'Domain ID', required: true }], + }, + { + name: 'delete', + description: 'Delete a domain', + positionals: [{ name: 'id', type: 'string', description: 'Domain ID', required: true }], + }, + ], + }, + // --- Workflow Commands --- + { + name: 'seed', + description: 'Seed WorkOS environment from a YAML config file', + options: [ + insecureStorageOpt, + apiKeyOpt, + { name: 'file', type: 'string', description: 'Path to seed YAML file', required: false, hidden: false }, + { + name: 'clean', + type: 'boolean', + description: 'Tear down seeded resources', + required: false, + default: false, + hidden: false, + }, + ], + }, + { + name: 'setup-org', + description: 'One-shot organization onboarding', + positionals: [{ name: 'name', type: 'string', description: 'Organization name', required: true }], + options: [ + insecureStorageOpt, + apiKeyOpt, + { name: 'domain', type: 'string', description: 'Domain to add', required: false, hidden: false }, + { name: 'roles', type: 'string', description: 'Comma-separated role slugs', required: false, hidden: false }, + ], + }, + { + name: 'onboard-user', + description: 'Onboard a user (send invitation, assign role)', + positionals: [{ name: 'email', type: 'string', description: 'User email', required: true }], + options: [ + insecureStorageOpt, + apiKeyOpt, + { name: 'org', type: 'string', description: 'Organization ID', required: true, hidden: false }, + { name: 'role', type: 'string', description: 'Role slug', required: false, hidden: false }, + { + name: 'wait', + type: 'boolean', + description: 'Wait for invitation acceptance', + required: false, + default: false, + hidden: false, + }, + ], + }, + { + name: 'debug-sso', + description: 'Diagnose SSO connection issues', + positionals: [{ name: 'connectionId', type: 'string', description: 'Connection ID', required: true }], + options: [insecureStorageOpt, apiKeyOpt], + }, + { + name: 'debug-sync', + description: 'Diagnose directory sync issues', + positionals: [{ name: 'directoryId', type: 'string', description: 'Directory ID', required: true }], + options: [insecureStorageOpt, apiKeyOpt], + }, { name: 'install', description: 'Install WorkOS AuthKit into your project (interactive framework detection and setup)', diff --git a/src/utils/register-subcommand.spec.ts b/src/utils/register-subcommand.spec.ts new file mode 100644 index 0000000..c43cf67 --- /dev/null +++ b/src/utils/register-subcommand.spec.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi } from 'vitest'; +import yargs from 'yargs'; +import { registerSubcommand } from './register-subcommand.js'; + +describe('registerSubcommand', () => { + it('enriches usage with one required string option', () => { + const parent = yargs([]); + const commandSpy = vi.spyOn(parent, 'command'); + + registerSubcommand( + parent, + 'create', + 'Create a resource', + (y) => y.options({ name: { type: 'string', demandOption: true, describe: 'Name' } }), + async () => {}, + ); + + expect(commandSpy).toHaveBeenCalledWith( + 'create --name ', + 'Create a resource', + expect.any(Function), + expect.any(Function), + ); + }); + + it('enriches usage with multiple required options', () => { + const parent = yargs([]); + const commandSpy = vi.spyOn(parent, 'command'); + + registerSubcommand( + parent, + 'send', + 'Send an invitation', + (y) => + y.options({ + email: { type: 'string', demandOption: true, describe: 'Email' }, + 'org-id': { type: 'string', demandOption: true, describe: 'Org ID' }, + }), + async () => {}, + ); + + const usageArg = commandSpy.mock.calls[0]![0] as string; + expect(usageArg).toContain('--email '); + expect(usageArg).toContain('--org-id '); + }); + + it('leaves usage unchanged when no required options', () => { + const parent = yargs([]); + const commandSpy = vi.spyOn(parent, 'command'); + + registerSubcommand( + parent, + 'list', + 'List resources', + (y) => y.options({ limit: { type: 'number' }, after: { type: 'string' } }), + async () => {}, + ); + + expect(commandSpy).toHaveBeenCalledWith('list', 'List resources', expect.any(Function), expect.any(Function)); + }); + + it('preserves positional args and appends required options', () => { + const parent = yargs([]); + const commandSpy = vi.spyOn(parent, 'command'); + + registerSubcommand( + parent, + 'remove ', + 'Remove a resource', + (y) => y.options({ force: { type: 'boolean', demandOption: true, describe: 'Force removal' } }), + async () => {}, + ); + + const usageArg = commandSpy.mock.calls[0]![0] as string; + expect(usageArg).toMatch(/^remove /); + expect(usageArg).toContain('--force '); + }); + + it('filters out help and version from enriched options', () => { + const parent = yargs([]); + const commandSpy = vi.spyOn(parent, 'command'); + + registerSubcommand( + parent, + 'get', + 'Get a resource', + (y) => y.options({ id: { type: 'string', demandOption: true, describe: 'ID' } }), + async () => {}, + ); + + const usageArg = commandSpy.mock.calls[0]![0] as string; + expect(usageArg).not.toContain('--help'); + expect(usageArg).not.toContain('--version'); + expect(usageArg).toContain('--id '); + }); + + it('handles number type option', () => { + const parent = yargs([]); + const commandSpy = vi.spyOn(parent, 'command'); + + registerSubcommand( + parent, + 'set', + 'Set a value', + (y) => y.options({ count: { type: 'number', demandOption: true, describe: 'Count' } }), + async () => {}, + ); + + expect(commandSpy).toHaveBeenCalledWith( + 'set --count ', + 'Set a value', + expect.any(Function), + expect.any(Function), + ); + }); + + it('returns the parent yargs instance', () => { + const parent = yargs([]); + const result = registerSubcommand( + parent, + 'test', + 'Test', + (y) => y, + async () => {}, + ); + expect(result).toBe(parent); + }); + + it('falls back to unenriched usage when builder throws', () => { + const parent = yargs([]); + const commandSpy = vi.spyOn(parent, 'command'); + + registerSubcommand( + parent, + 'broken', + 'Broken command', + () => { + throw new Error('boom'); + }, + async () => {}, + ); + + expect(commandSpy).toHaveBeenCalledWith('broken', 'Broken command', expect.any(Function), expect.any(Function)); + }); +}); diff --git a/src/utils/register-subcommand.ts b/src/utils/register-subcommand.ts new file mode 100644 index 0000000..b87341d --- /dev/null +++ b/src/utils/register-subcommand.ts @@ -0,0 +1,53 @@ +import yargs from 'yargs'; +import type { Argv } from 'yargs'; + +interface YargsOptions { + demandedOptions: Record; + string: string[]; + number: string[]; + boolean: string[]; +} + +/** + * Register a subcommand with auto-enriched usage string. + * Replays the builder on a probe yargs instance to discover demandOption fields, + * then appends them to the usage string so parent help shows required args. + */ +export function registerSubcommand( + parentYargs: Argv, + usage: string, + description: string, + builder: (y: Argv) => Argv, + handler: (argv: any) => Promise, +): Argv { + let enrichedUsage = usage; + + try { + const probe = yargs([]); + builder(probe); + // getOptions() exists at runtime but is not in yargs' public type definitions + const opts = (probe as unknown as { getOptions(): YargsOptions }).getOptions(); + const demanded = Object.keys(opts.demandedOptions || {}).filter((k) => !['help', 'version'].includes(k)); + + const requiredSuffix = demanded + .map((k) => { + const type = opts.string.includes(k) + ? 'string' + : opts.number.includes(k) + ? 'number' + : opts.boolean.includes(k) + ? 'boolean' + : 'value'; + return `--${k} <${type}>`; + }) + .join(' '); + + if (requiredSuffix) { + enrichedUsage = `${usage} ${requiredSuffix}`; + } + } catch { + // Builder threw during probe — fall back to unenriched usage + } + + return parentYargs.command(enrichedUsage, description, builder, handler); +}