From 68e70a27ce14497c706557b54ac08eb4afb39dab Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 2 Mar 2026 14:15:08 -0600 Subject: [PATCH 01/16] feat: add unified WorkOS SDK client and migrate org/user commands Replace raw `workosRequest()` fetch calls in organization and user commands with the `@workos-inc/node` SDK. Introduce a unified client (`workos-client.ts`) that wraps the SDK for documented endpoints and extends with raw-fetch methods for undocumented ones (webhooks, redirect URIs, CORS origins, homepage URL). --- package.json | 1 + pnpm-lock.yaml | 25 ++++ src/commands/organization.spec.ts | 144 ++++++++++----------- src/commands/organization.ts | 96 +++++--------- src/commands/user.spec.ts | 123 ++++++++++-------- src/commands/user.ts | 83 +++++------- src/lib/workos-client.spec.ts | 203 ++++++++++++++++++++++++++++++ src/lib/workos-client.ts | 142 +++++++++++++++++++++ 8 files changed, 559 insertions(+), 258 deletions(-) create mode 100644 src/lib/workos-client.spec.ts create mode 100644 src/lib/workos-client.ts diff --git a/package.json b/package.json index 5636200..5da889f 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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef656d5..15a75de 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 @@ -1582,6 +1585,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 +1968,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 +2422,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==} @@ -3794,6 +3808,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 +4202,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 +4713,8 @@ snapshots: typescript@5.9.3: {} + uint8array-extras@1.5.0: {} + undici-types@5.26.5: {} undici-types@6.11.1: {} 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/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/lib/workos-client.spec.ts b/src/lib/workos-client.spec.ts new file mode 100644 index 0000000..7399246 --- /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: { 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..4f73e88 --- /dev/null +++ b/src/lib/workos-client.ts @@ -0,0 +1,142 @@ +/** + * 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[]; + created_at: string; + updated_at: string; +} + +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; + }; +} + +/** + * 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: { 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 }, + }); + }, + }, + }; +} From f444625bbfcba55c1c69461812d0d46b15f8d3f3 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 2 Mar 2026 14:57:31 -0600 Subject: [PATCH 02/16] feat: add CLI management commands for all WorkOS resources Implement 12 command groups (phases 2-6) covering the full WorkOS management API surface: - role, permission (RBAC with env/org-scoped branching) - membership, invitation, session (user lifecycle) - connection, directory (SSO & directory sync, read/delete with confirm) - event, audit-log (observability with export polling) - feature-flag, webhook, config, portal, vault, api-key, org-domain Extends workos-client.ts with auditLogs extension methods for undocumented endpoints. All commands follow the established SDK client pattern with JSON/human output modes and structured error handling. 229 new tests across 16 spec files (894 total). --- src/commands/api-key-mgmt.spec.ts | 175 +++++++++++++ src/commands/api-key-mgmt.ts | 127 ++++++++++ src/commands/audit-log.spec.ts | 402 ++++++++++++++++++++++++++++++ src/commands/audit-log.ts | 224 +++++++++++++++++ src/commands/config.spec.ts | 108 ++++++++ src/commands/config.ts | 59 +++++ src/commands/connection.spec.ts | 209 ++++++++++++++++ src/commands/connection.ts | 126 ++++++++++ src/commands/directory.spec.ts | 344 +++++++++++++++++++++++++ src/commands/directory.ts | 242 ++++++++++++++++++ src/commands/event.spec.ts | 142 +++++++++++ src/commands/event.ts | 57 +++++ src/commands/feature-flag.spec.ts | 185 ++++++++++++++ src/commands/feature-flag.ts | 128 ++++++++++ src/commands/invitation.spec.ts | 221 ++++++++++++++++ src/commands/invitation.ts | 137 ++++++++++ src/commands/membership.spec.ts | 268 ++++++++++++++++++++ src/commands/membership.ts | 173 +++++++++++++ src/commands/org-domain.spec.ts | 130 ++++++++++ src/commands/org-domain.ts | 54 ++++ src/commands/permission.spec.ts | 261 +++++++++++++++++++ src/commands/permission.ts | 137 ++++++++++ src/commands/portal.spec.ts | 76 ++++++ src/commands/portal.ts | 40 +++ src/commands/role.spec.ts | 330 ++++++++++++++++++++++++ src/commands/role.ts | 202 +++++++++++++++ src/commands/session.spec.ts | 139 +++++++++++ src/commands/session.ts | 85 +++++++ src/commands/vault.spec.ts | 203 +++++++++++++++ src/commands/vault.ts | 154 ++++++++++++ src/commands/webhook.spec.ts | 131 ++++++++++ src/commands/webhook.ts | 91 +++++++ src/lib/workos-client.ts | 41 +++ 33 files changed, 5401 insertions(+) create mode 100644 src/commands/api-key-mgmt.spec.ts create mode 100644 src/commands/api-key-mgmt.ts create mode 100644 src/commands/audit-log.spec.ts create mode 100644 src/commands/audit-log.ts create mode 100644 src/commands/config.spec.ts create mode 100644 src/commands/config.ts create mode 100644 src/commands/connection.spec.ts create mode 100644 src/commands/connection.ts create mode 100644 src/commands/directory.spec.ts create mode 100644 src/commands/directory.ts create mode 100644 src/commands/event.spec.ts create mode 100644 src/commands/event.ts create mode 100644 src/commands/feature-flag.spec.ts create mode 100644 src/commands/feature-flag.ts create mode 100644 src/commands/invitation.spec.ts create mode 100644 src/commands/invitation.ts create mode 100644 src/commands/membership.spec.ts create mode 100644 src/commands/membership.ts create mode 100644 src/commands/org-domain.spec.ts create mode 100644 src/commands/org-domain.ts create mode 100644 src/commands/permission.spec.ts create mode 100644 src/commands/permission.ts create mode 100644 src/commands/portal.spec.ts create mode 100644 src/commands/portal.ts create mode 100644 src/commands/role.spec.ts create mode 100644 src/commands/role.ts create mode 100644 src/commands/session.spec.ts create mode 100644 src/commands/session.ts create mode 100644 src/commands/vault.spec.ts create mode 100644 src/commands/vault.ts create mode 100644 src/commands/webhook.spec.ts create mode 100644 src/commands/webhook.ts 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..43a94a2 --- /dev/null +++ b/src/commands/api-key-mgmt.ts @@ -0,0 +1,127 @@ +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..543ecaf --- /dev/null +++ b/src/commands/audit-log.ts @@ -0,0 +1,224 @@ +import chalk from 'chalk'; +import { readFile } from 'node:fs/promises'; +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: Record; + + 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) }), + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await client.sdk.auditLogs.createEvent(orgId, event as any); + outputSuccess('Created audit log event', { organization_id: orgId, action: event.action as string }); + } 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..65e0cbb --- /dev/null +++ b/src/commands/connection.ts @@ -0,0 +1,126 @@ +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('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 any }), + 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/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..ac51da1 --- /dev/null +++ b/src/commands/directory.ts @@ -0,0 +1,242 @@ +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/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..21c0dcd --- /dev/null +++ b/src/commands/invitation.ts @@ -0,0 +1,137 @@ +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/org-domain.spec.ts b/src/commands/org-domain.spec.ts new file mode 100644 index 0000000..8a6b25a --- /dev/null +++ b/src/commands/org-domain.spec.ts @@ -0,0 +1,130 @@ +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/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..a305fad --- /dev/null +++ b/src/commands/permission.ts @@ -0,0 +1,137 @@ +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..910b6f9 --- /dev/null +++ b/src/commands/portal.spec.ts @@ -0,0 +1,76 @@ +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..e779d26 --- /dev/null +++ b/src/commands/role.ts @@ -0,0 +1,202 @@ +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('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/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/vault.spec.ts b/src/commands/vault.spec.ts new file mode 100644 index 0000000..c9f574a --- /dev/null +++ b/src/commands/vault.spec.ts @@ -0,0 +1,203 @@ +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..9d99ce5 --- /dev/null +++ b/src/commands/vault.ts @@ -0,0 +1,154 @@ +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..79bdca9 --- /dev/null +++ b/src/commands/webhook.ts @@ -0,0 +1,91 @@ +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/workos-client.ts b/src/lib/workos-client.ts index 4f73e88..cda76f4 100644 --- a/src/lib/workos-client.ts +++ b/src/lib/workos-client.ts @@ -14,10 +14,19 @@ 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: { @@ -34,6 +43,11 @@ export interface WorkOSCLIClient { homepageUrl: { set(url: string): Promise; }; + auditLogs: { + listActions(): Promise>; + getSchema(action: string): Promise; + getRetention(orgId: string): Promise; + }; } /** @@ -138,5 +152,32 @@ export function createWorkOSClient(apiKey?: string, baseUrl?: string): WorkOSCLI }); }, }, + + 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, + }); + }, + }, }; } From 2d8a6e6ed777fc5198b35d18d759bffeaa7cc1ff Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 2 Mar 2026 15:07:56 -0600 Subject: [PATCH 03/16] feat: add compound workflow commands (seed, setup-org, debug) Add 5 orchestration commands that compose existing SDK operations: - seed: declarative YAML-based resource provisioning with state tracking and --clean teardown - setup-org: one-shot org onboarding (create, domain, roles, portal) - onboard-user: invitation workflow with optional --wait polling - debug-sso: SSO connection diagnostics with event history - debug-sync: directory sync diagnostics with user/group counts Adds yaml dependency for seed file parsing. 16 new tests (916 total). --- package.json | 1 + pnpm-lock.yaml | 40 ++++- src/commands/debug-sso.spec.ts | 103 ++++++++++++ src/commands/debug-sso.ts | 80 +++++++++ src/commands/debug-sync.spec.ts | 115 +++++++++++++ src/commands/debug-sync.ts | 107 ++++++++++++ src/commands/onboard-user.spec.ts | 66 ++++++++ src/commands/onboard-user.ts | 69 ++++++++ src/commands/seed.spec.ts | 195 ++++++++++++++++++++++ src/commands/seed.ts | 260 ++++++++++++++++++++++++++++++ src/commands/setup-org.spec.ts | 82 ++++++++++ src/commands/setup-org.ts | 96 +++++++++++ 12 files changed, 1206 insertions(+), 8 deletions(-) create mode 100644 src/commands/debug-sso.spec.ts create mode 100644 src/commands/debug-sso.ts create mode 100644 src/commands/debug-sync.spec.ts create mode 100644 src/commands/debug-sync.ts create mode 100644 src/commands/onboard-user.spec.ts create mode 100644 src/commands/onboard-user.ts create mode 100644 src/commands/seed.spec.ts create mode 100644 src/commands/seed.ts create mode 100644 src/commands/setup-org.spec.ts create mode 100644 src/commands/setup-org.ts diff --git a/package.json b/package.json index 5da889f..426ce94 100644 --- a/package.json +++ b/package.json @@ -52,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 15a75de..27c9fae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,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 @@ -104,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: @@ -2620,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'} @@ -3695,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: @@ -3733,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: @@ -3796,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: @@ -4762,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 @@ -4840,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 @@ -4860,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 @@ -4930,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/src/commands/debug-sso.spec.ts b/src/commands/debug-sso.spec.ts new file mode 100644 index 0000000..348fc5a --- /dev/null +++ b/src/commands/debug-sso.spec.ts @@ -0,0 +1,103 @@ +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()); + + it('displays connection details', async () => { + mockSdk.sso.getConnection.mockResolvedValue({ + id: 'conn_123', + name: 'Okta SSO', + type: 'OktaSAML', + state: 'active', + organizationId: 'org_123', + createdAt: '2024-01-01', + }); + 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('active'))).toBe(true); + }); + + it('identifies inactive connection', async () => { + mockSdk.sso.getConnection.mockResolvedValue({ + id: 'conn_123', + name: 'Broken SSO', + type: 'OktaSAML', + state: 'inactive', + organizationId: 'org_123', + createdAt: '2024-01-01', + }); + 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({ + id: 'conn_123', + name: 'SSO', + type: 'OktaSAML', + state: 'active', + organizationId: null, + createdAt: '2024-01-01', + }); + mockSdk.events.listEvents.mockResolvedValue({ + data: [{ id: 'evt_1', event: 'authentication.sso_succeeded', createdAt: '2024-01-02' }], + listMetadata: {}, + }); + + await runDebugSso('conn_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('sso_succeeded'))).toBe(true); + }); + + describe('JSON mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('outputs full diagnosis as JSON', async () => { + mockSdk.sso.getConnection.mockResolvedValue({ + id: 'conn_123', + name: 'SSO', + type: 'OktaSAML', + state: 'inactive', + organizationId: 'org_123', + createdAt: '2024-01-01', + }); + 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.issues).toContain('Connection is inactive (not active)'); + }); + }); +}); diff --git a/src/commands/debug-sso.ts b/src/commands/debug-sso.ts new file mode 100644 index 0000000..7ae9f38 --- /dev/null +++ b/src/commands/debug-sso.ts @@ -0,0 +1,80 @@ +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..a4335d3 --- /dev/null +++ b/src/commands/debug-sync.spec.ts @@ -0,0 +1,115 @@ +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()); + + it('displays directory details with user/group counts', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue({ + id: 'dir_123', + name: 'Okta SCIM', + type: 'okta scim v2.0', + state: 'linked', + organizationId: 'org_123', + createdAt: '2024-01-01', + }); + mockSdk.directorySync.listUsers.mockResolvedValue({ data: [{ id: 'u1' }], listMetadata: { after: null } }); + mockSdk.directorySync.listGroups.mockResolvedValue({ data: [{ id: 'g1' }], listMetadata: { after: null } }); + mockSdk.events.listEvents.mockResolvedValue({ + data: [{ id: 'evt_1', event: 'dsync.user.created', createdAt: '2024-01-02' }], + listMetadata: {}, + }); + + await runDebugSync('dir_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('Okta SCIM'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('linked'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('dsync.user.created'))).toBe(true); + }); + + it('identifies unlinked directory', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue({ + id: 'dir_123', + name: 'Broken Dir', + type: 'okta scim v2.0', + state: 'unlinked', + organizationId: null, + createdAt: '2024-01-01', + }); + mockSdk.directorySync.listUsers.mockResolvedValue({ data: [], listMetadata: { after: null } }); + mockSdk.directorySync.listGroups.mockResolvedValue({ data: [], listMetadata: { after: null } }); + mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + + await runDebugSync('dir_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('not linked'))).toBe(true); + }); + + it('warns when no sync events found', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue({ + id: 'dir_123', + name: 'Dir', + type: 'okta scim v2.0', + state: 'linked', + organizationId: null, + createdAt: '2024-01-01', + }); + mockSdk.directorySync.listUsers.mockResolvedValue({ data: [], listMetadata: { after: null } }); + mockSdk.directorySync.listGroups.mockResolvedValue({ data: [], listMetadata: { after: null } }); + mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + + await runDebugSync('dir_123', 'sk_test'); + + expect(consoleOutput.some((l) => l.includes('stalled'))).toBe(true); + }); + + describe('JSON mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('outputs full diagnosis as JSON', async () => { + mockSdk.directorySync.getDirectory.mockResolvedValue({ + id: 'dir_123', + name: 'Dir', + type: 'okta scim v2.0', + state: 'unlinked', + organizationId: null, + createdAt: '2024-01-01', + }); + mockSdk.directorySync.listUsers.mockResolvedValue({ data: [], listMetadata: { after: null } }); + mockSdk.directorySync.listGroups.mockResolvedValue({ data: [], listMetadata: { after: null } }); + mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + + await runDebugSync('dir_123', 'sk_test'); + + const output = JSON.parse(consoleOutput[0]); + expect(output.directory.id).toBe('dir_123'); + expect(output.issues.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/commands/debug-sync.ts b/src/commands/debug-sync.ts new file mode 100644 index 0000000..33431f3 --- /dev/null +++ b/src/commands/debug-sync.ts @@ -0,0 +1,107 @@ +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/onboard-user.spec.ts b/src/commands/onboard-user.spec.ts new file mode 100644 index 0000000..24712fa --- /dev/null +++ b/src/commands/onboard-user.spec.ts @@ -0,0 +1,66 @@ +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(); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => 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' }), + ); + }); + + describe('JSON mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('outputs JSON summary', 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'); + }); + }); +}); 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/seed.spec.ts b/src/commands/seed.spec.ts new file mode 100644 index 0000000..38e6eaf --- /dev/null +++ b/src/commands/seed.spec.ts @@ -0,0 +1,195 @@ +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 SEED_YAML = ` +organizations: + - name: "Test Org" + domains: ["test.com"] +permissions: + - name: "Read Users" + slug: "read-users" +roles: + - name: "Admin" + slug: "admin" + 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', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(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'); + + // Verify order: permissions first + expect(mockSdk.authorization.createPermission).toHaveBeenCalledWith( + expect.objectContaining({ slug: 'read-users' }), + ); + // Then roles + expect(mockSdk.authorization.createEnvironmentRole).toHaveBeenCalledWith( + expect.objectContaining({ slug: 'admin' }), + ); + // Then permission assignment + expect(mockSdk.authorization.setEnvironmentRolePermissions).toHaveBeenCalledWith('admin', { + 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(); + expect(consoleOutput.some((l) => l.includes('Seed complete'))).toBe(true); + }); + + it('skips already-existing resources', 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); + }); + + 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); + }); + }); + + describe('runSeed --clean', () => { + it('deletes resources in reverse order', async () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue( + JSON.stringify({ + permissions: [{ slug: 'read-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'); + + // Orgs deleted first (reverse of creation order) + expect(mockSdk.organizations.deleteOrganization).toHaveBeenCalledWith('org_123'); + // Permissions deleted + expect(mockSdk.authorization.deletePermission).toHaveBeenCalledWith('read-users'); + // State file removed + 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 on 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.state.permissions).toHaveLength(1); + }); + }); +}); diff --git a/src/commands/seed.ts b/src/commands/seed.ts new file mode 100644 index 0000000..ca5ab2c --- /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 (error: unknown) { + 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 + saveState(state); + if (isJsonMode()) { + outputJson({ status: 'error', message: error instanceof Error ? error.message : 'Seed failed', partialState: state }); + } else { + console.error(chalk.red(`\nSeed failed: ${error instanceof Error ? error.message : 'Unknown error'}`)); + console.log(chalk.dim(`Partial state saved to ${STATE_FILE}. Run \`workos seed --clean\` to tear down.`)); + } + process.exit(1); + } +} + +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/setup-org.spec.ts b/src/commands/setup-org.spec.ts new file mode 100644 index 0000000..d7c4fa3 --- /dev/null +++ b/src/commands/setup-org.spec.ts @@ -0,0 +1,82 @@ +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', 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('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'); + }); + + 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' }); + }); + + 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' })); + }); + + describe('JSON mode', () => { + beforeEach(() => setOutputMode('json')); + afterEach(() => setOutputMode('human')); + + it('outputs JSON summary', 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'); + }); + }); +}); diff --git a/src/commands/setup-org.ts b/src/commands/setup-org.ts new file mode 100644 index 0000000..f2ec8d0 --- /dev/null +++ b/src/commands/setup-org.ts @@ -0,0 +1,96 @@ +import chalk from 'chalk'; +import { createWorkOSClient } from '../lib/workos-client.js'; +import { outputJson, isJsonMode, exitWithError } 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); + } +} From 44603c7e214003980b5500a599341790741d6b4b Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 2 Mar 2026 16:17:50 -0600 Subject: [PATCH 04/16] feat: add agent skill for management commands Auto-discovered by install-skill via skills/workos-management/SKILL.md. Covers full command reference, workflow recipes (RBAC, org/user onboarding, SSO/DSync debugging, seeding), --json usage guide, and dashboard-only negative guidance. --- skills/workos-management/SKILL.md | 249 ++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 skills/workos-management/SKILL.md diff --git a/skills/workos-management/SKILL.md b/skills/workos-management/SKILL.md new file mode 100644 index 0000000..5bb75af --- /dev/null +++ b/skills/workos-management/SKILL.md @@ -0,0 +1,249 @@ +--- +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 From f10d1da4722824c394a2c3bcb0c93e5b3e0900ee Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 2 Mar 2026 17:43:38 -0600 Subject: [PATCH 05/16] feat: register all management commands in CLI Wire up 19 new command groups in bin.ts (role, permission, membership, invitation, session, connection, directory, event, audit-log, feature-flag, webhook, config, portal, vault, api-key, org-domain, seed, setup-org, onboard-user, debug-sso, debug-sync). Update help-json.ts with all 29 command schemas for --help --json. --- src/bin.ts | 724 +++++++++++++++++++++++++++++++++++++++++ src/utils/help-json.ts | 190 +++++++++++ 2 files changed, 914 insertions(+) diff --git a/src/bin.ts b/src/bin.ts index c7880a5..5489593 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -522,6 +522,730 @@ yargs(rawArgs) .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)' }, + }) + .command( + 'list', + 'List roles', + (yargs) => yargs, + 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()); + }, + ) + .command( + 'get ', + 'Get a role by slug', + (yargs) => yargs.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()); + }, + ) + .command( + 'create', + 'Create a role', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'update ', + 'Update a role', + (yargs) => + yargs + .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(), + ); + }, + ) + .command( + 'delete ', + 'Delete an org-scoped role (requires --org)', + (yargs) => yargs.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()); + }, + ) + .command( + 'set-permissions ', + 'Set all permissions on a role (replaces existing)', + (yargs) => + yargs + .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(), + ); + }, + ) + .command( + 'add-permission ', + 'Add a permission to a role', + (yargs) => + yargs + .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(), + ); + }, + ) + .command( + 'remove-permission ', + 'Remove a permission from an org role (requires --org)', + (yargs) => + yargs + .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(), + ); + }, + ) + .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' } }) + .command('list', 'List permissions', (yargs) => yargs.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()); + }) + .command('get ', 'Get a permission', (yargs) => yargs.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()); + }) + .command('create', 'Create a permission', (yargs) => yargs.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()); + }) + .command('update ', 'Update a permission', (yargs) => yargs.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()); + }) + .command('delete ', 'Delete a permission', (yargs) => yargs.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()); + }) + .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' } }) + .command('list', 'List memberships', (yargs) => yargs.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()); + }) + .command('get ', 'Get a membership', (yargs) => yargs.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()); + }) + .command('create', 'Create a membership', (yargs) => yargs.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()); + }) + .command('update ', 'Update a membership', (yargs) => yargs.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()); + }) + .command('delete ', 'Delete a membership', (yargs) => yargs.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()); + }) + .command('deactivate ', 'Deactivate a membership', (yargs) => yargs.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()); + }) + .command('reactivate ', 'Reactivate a membership', (yargs) => yargs.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()); + }) + .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' } }) + .command('list', 'List invitations', (yargs) => yargs.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()); + }) + .command('get ', 'Get an invitation', (yargs) => yargs.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()); + }) + .command('send', 'Send an invitation', (yargs) => yargs.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()); + }) + .command('revoke ', 'Revoke an invitation', (yargs) => yargs.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()); + }) + .command('resend ', 'Resend an invitation', (yargs) => yargs.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()); + }) + .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' } }) + .command('list ', 'List sessions for a user', (yargs) => yargs.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()); + }) + .command('revoke ', 'Revoke a session', (yargs) => yargs.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()); + }) + .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' } }) + .command('list', 'List connections', (yargs) => yargs.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()); + }) + .command('get ', 'Get a connection', (yargs) => yargs.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()); + }) + .command('delete ', 'Delete a connection', (yargs) => yargs.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()); + }) + .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' } }) + .command('list', 'List directories', (yargs) => yargs.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()); + }) + .command('get ', 'Get a directory', (yargs) => yargs.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()); + }) + .command('delete ', 'Delete a directory', (yargs) => yargs.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()); + }) + .command('list-users', 'List directory users', (yargs) => yargs.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()); + }) + .command('list-groups', 'List directory groups', (yargs) => yargs.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()); + }) + .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' } }) + .command('list', 'List events', (yargs) => yargs.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()); + }) + .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' } }) + .command('create-event ', 'Create an audit log event', (yargs) => yargs.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()); + }) + .command('export', 'Export audit logs', (yargs) => yargs.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()); + }) + .command('list-actions', 'List available audit log actions', (yargs) => yargs, 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()); + }) + .command('get-schema ', 'Get schema for an audit log action', (yargs) => yargs.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()); + }) + .command('create-schema ', 'Create an audit log schema', (yargs) => yargs.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()); + }) + .command('get-retention ', 'Get audit log retention period', (yargs) => yargs.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()); + }) + .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' } }) + .command('list', 'List feature flags', (yargs) => yargs.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()); + }) + .command('get ', 'Get a feature flag', (yargs) => yargs.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()); + }) + .command('enable ', 'Enable a feature flag', (yargs) => yargs.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()); + }) + .command('disable ', 'Disable a feature flag', (yargs) => yargs.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()); + }) + .command('add-target ', 'Add a target to a feature flag', (yargs) => yargs.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()); + }) + .command('remove-target ', 'Remove a target from a feature flag', (yargs) => yargs.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()); + }) + .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' } }) + .command('list', 'List webhooks', (yargs) => yargs, 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()); + }) + .command('create', 'Create a webhook', (yargs) => yargs.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()); + }) + .command('delete ', 'Delete a webhook', (yargs) => yargs.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()); + }) + .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' } }) + .command('redirect', 'Manage redirect URIs', (yargs) => + yargs + .command('add ', 'Add a redirect URI', (yargs) => yargs.positional('uri', { type: 'string', demandOption: true }), async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConfigRedirectAdd } = await import('./commands/config.js'); + await runConfigRedirectAdd(argv.uri, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }) + .demandCommand(1) + .strict(), + ) + .command('cors', 'Manage CORS origins', (yargs) => + yargs + .command('add ', 'Add a CORS origin', (yargs) => yargs.positional('origin', { type: 'string', demandOption: true }), async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConfigCorsAdd } = await import('./commands/config.js'); + await runConfigCorsAdd(argv.origin, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }) + .demandCommand(1) + .strict(), + ) + .command('homepage-url', 'Manage homepage URL', (yargs) => + yargs + .command('set ', 'Set the homepage URL', (yargs) => yargs.positional('url', { type: 'string', demandOption: true }), async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConfigHomepageUrlSet } = await import('./commands/config.js'); + await runConfigHomepageUrlSet(argv.url, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }) + .demandCommand(1) + .strict(), + ) + .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' } }) + .command('generate-link', 'Generate an Admin Portal link', (yargs) => yargs.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()); + }) + .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' } }) + .command('list', 'List vault objects', (yargs) => yargs.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()); + }) + .command('get ', 'Get a vault object', (yargs) => yargs.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()); + }) + .command('get-by-name ', 'Get a vault object by name', (yargs) => yargs.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()); + }) + .command('create', 'Create a vault object', (yargs) => yargs.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()); + }) + .command('update ', 'Update a vault object', (yargs) => yargs.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()); + }) + .command('delete ', 'Delete a vault object', (yargs) => yargs.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()); + }) + .command('describe ', 'Describe a vault object', (yargs) => yargs.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()); + }) + .command('list-versions ', 'List vault object versions', (yargs) => yargs.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()); + }) + .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' } }) + .command('list', 'List API keys', (yargs) => yargs.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()); + }) + .command('create', 'Create an API key', (yargs) => yargs.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()); + }) + .command('validate ', 'Validate an API key', (yargs) => yargs.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()); + }) + .command('delete ', 'Delete an API key', (yargs) => yargs.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()); + }) + .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' } }) + .command('get ', 'Get a domain', (yargs) => yargs.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()); + }) + .command('create ', 'Create a domain', (yargs) => yargs.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()); + }) + .command('verify ', 'Verify a domain', (yargs) => yargs.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()); + }) + .command('delete ', 'Delete a domain', (yargs) => yargs.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()); + }) + .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( + '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' }, + 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', 'Install WorkOS AuthKit into your project (interactive framework detection and setup)', diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 341d619..39249e0 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -348,6 +348,196 @@ 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)', From 6a1a6880fb7e8f9998e751bae52d269bf96dc9e4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 2 Mar 2026 18:16:48 -0600 Subject: [PATCH 06/16] fix: handle SDK exceptions in api-error-handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add duck-type detection for @workos-inc/node SDK exceptions alongside the existing WorkOSApiError check. SDK errors (UnauthorizedException, NotFoundException, etc.) now produce clean user-facing messages instead of crashing with "Unknown error". Revert toSnakeCase output conversion — JSON output conforms to the Node SDK's camelCase format. --- src/lib/api-error-handler.spec.ts | 142 ++++++++++++++++++++++++++++++ src/lib/api-error-handler.ts | 35 ++++++++ src/utils/output.spec.ts | 1 + 3 files changed, 178 insertions(+) create mode 100644 src/lib/api-error-handler.spec.ts diff --git a/src/lib/api-error-handler.spec.ts b/src/lib/api-error-handler.spec.ts new file mode 100644 index 0000000..5b16268 --- /dev/null +++ b/src/lib/api-error-handler.spec.ts @@ -0,0 +1,142 @@ +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/utils/output.spec.ts b/src/utils/output.spec.ts index 43296cb..17fb5b7 100644 --- a/src/utils/output.spec.ts +++ b/src/utils/output.spec.ts @@ -81,6 +81,7 @@ describe('output', () => { expect(spy).toHaveBeenCalledWith('[1,2,3]'); spy.mockRestore(); }); + }); describe('outputError', () => { From bd4a47af45381109d967e3f30114bd34345e5bb6 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 2 Mar 2026 18:19:07 -0600 Subject: [PATCH 07/16] fix: remove unsafe type casts in audit-log and connection commands Replace `as any` with proper SDK types: CreateAuditLogEventOptions for audit log events, ConnectionType for SSO connection type filter. --- src/commands/audit-log.ts | 8 ++++---- src/commands/connection.ts | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/commands/audit-log.ts b/src/commands/audit-log.ts index 543ecaf..bae4864 100644 --- a/src/commands/audit-log.ts +++ b/src/commands/audit-log.ts @@ -1,5 +1,6 @@ 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'; @@ -30,7 +31,7 @@ export async function runAuditLogCreateEvent( const client = createWorkOSClient(apiKey, baseUrl); try { - let event: Record; + let event: CreateAuditLogEventOptions; if (flags.file) { const raw = await readFile(flags.file, 'utf-8'); @@ -53,9 +54,8 @@ export async function runAuditLogCreateEvent( }; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await client.sdk.auditLogs.createEvent(orgId, event as any); - outputSuccess('Created audit log event', { organization_id: orgId, action: event.action as string }); + await client.sdk.auditLogs.createEvent(orgId, event); + outputSuccess('Created audit log event', { organization_id: orgId, action: event.action }); } catch (error) { handleApiError(error); } diff --git a/src/commands/connection.ts b/src/commands/connection.ts index 65e0cbb..45081f2 100644 --- a/src/commands/connection.ts +++ b/src/commands/connection.ts @@ -1,4 +1,5 @@ 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'; @@ -27,7 +28,7 @@ export async function runConnectionList( try { const result = await client.sdk.sso.listConnections({ ...(options.organizationId && { organizationId: options.organizationId }), - ...(options.connectionType && { connectionType: options.connectionType as any }), + ...(options.connectionType && { connectionType: options.connectionType as ConnectionType }), limit: options.limit, before: options.before, after: options.after, From 716a463e259e5dcea83e3cf019ce1cb1cbe34bba Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 2 Mar 2026 18:21:34 -0600 Subject: [PATCH 08/16] chore: formatting and linting --- skills/workos-management/SKILL.md | 153 +-- src/bin.ts | 1451 +++++++++++++++++++++-------- src/commands/api-key-mgmt.ts | 7 +- src/commands/debug-sso.ts | 6 +- src/commands/debug-sync.ts | 4 +- src/commands/directory.ts | 6 +- src/commands/install.ts | 1 - src/commands/invitation.ts | 8 +- src/commands/onboard-user.spec.ts | 4 +- src/commands/org-domain.spec.ts | 4 +- src/commands/permission.ts | 5 +- src/commands/portal.spec.ts | 7 +- src/commands/role.ts | 22 +- src/commands/seed.ts | 34 +- src/commands/setup-org.spec.ts | 5 +- src/commands/setup-org.ts | 5 +- src/commands/vault.spec.ts | 20 +- src/commands/vault.ts | 6 +- src/commands/webhook.ts | 14 +- src/lib/api-error-handler.spec.ts | 20 +- src/utils/help-json.ts | 659 +++++++++++-- src/utils/output.spec.ts | 1 - 22 files changed, 1804 insertions(+), 638 deletions(-) diff --git a/skills/workos-management/SKILL.md b/skills/workos-management/SKILL.md index 5bb75af..62bb81d 100644 --- a/skills/workos-management/SKILL.md +++ b/skills/workos-management/SKILL.md @@ -11,32 +11,32 @@ All commands support `--json` for structured output. Use `--json` when you need ## 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` | +| 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 @@ -101,27 +101,27 @@ Create a `workos-seed.yml` file in your repo: ```yaml permissions: - - name: "Read Users" - slug: "read-users" - - name: "Write Users" - slug: "write-users" + - 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"] + - name: 'Admin' + slug: 'admin' + permissions: ['read-users', 'write-users'] + - name: 'Viewer' + slug: 'viewer' + permissions: ['read-users'] organizations: - - name: "Test Org" - domains: ["test.com"] + - name: 'Test Org' + domains: ['test.com'] config: - redirect_uris: ["http://localhost:3000/callback"] - cors_origins: ["http://localhost:3000"] - homepage_url: "http://localhost:3000" + redirect_uris: ['http://localhost:3000/callback'] + cors_origins: ['http://localhost:3000'] + homepage_url: 'http://localhost:3000' ``` Then run: @@ -185,6 +185,7 @@ 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": {...} }` @@ -194,47 +195,47 @@ JSON output format: ### 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` | +| 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 | +| 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 | +| 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 diff --git a/src/bin.ts b/src/bin.ts index 5489593..2a1c00f 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -610,7 +610,11 @@ yargs(rawArgs) (yargs) => yargs .positional('slug', { type: 'string', demandOption: true }) - .option('permissions', { type: 'string', demandOption: true, describe: 'Comma-separated permission slugs' }), + .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'); @@ -671,318 +675,808 @@ yargs(rawArgs) .command('permission', 'Manage WorkOS permissions', (yargs) => yargs .options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }) - .command('list', 'List permissions', (yargs) => yargs.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()); - }) - .command('get ', 'Get a permission', (yargs) => yargs.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()); - }) - .command('create', 'Create a permission', (yargs) => yargs.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()); - }) - .command('update ', 'Update a permission', (yargs) => yargs.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()); - }) - .command('delete ', 'Delete a permission', (yargs) => yargs.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()); - }) + .command( + 'list', + 'List permissions', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'get ', + 'Get a permission', + (yargs) => yargs.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()); + }, + ) + .command( + 'create', + 'Create a permission', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'update ', + 'Update a permission', + (yargs) => + yargs + .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(), + ); + }, + ) + .command( + 'delete ', + 'Delete a permission', + (yargs) => yargs.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()); + }, + ) .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' } }) - .command('list', 'List memberships', (yargs) => yargs.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()); - }) - .command('get ', 'Get a membership', (yargs) => yargs.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()); - }) - .command('create', 'Create a membership', (yargs) => yargs.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()); - }) - .command('update ', 'Update a membership', (yargs) => yargs.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()); - }) - .command('delete ', 'Delete a membership', (yargs) => yargs.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()); - }) - .command('deactivate ', 'Deactivate a membership', (yargs) => yargs.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()); - }) - .command('reactivate ', 'Reactivate a membership', (yargs) => yargs.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()); - }) + .command( + 'list', + 'List memberships', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'get ', + 'Get a membership', + (yargs) => yargs.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()); + }, + ) + .command( + 'create', + 'Create a membership', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'update ', + 'Update a membership', + (yargs) => yargs.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()); + }, + ) + .command( + 'delete ', + 'Delete a membership', + (yargs) => yargs.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()); + }, + ) + .command( + 'deactivate ', + 'Deactivate a membership', + (yargs) => yargs.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()); + }, + ) + .command( + 'reactivate ', + 'Reactivate a membership', + (yargs) => yargs.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()); + }, + ) .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' } }) - .command('list', 'List invitations', (yargs) => yargs.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()); - }) - .command('get ', 'Get an invitation', (yargs) => yargs.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()); - }) - .command('send', 'Send an invitation', (yargs) => yargs.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()); - }) - .command('revoke ', 'Revoke an invitation', (yargs) => yargs.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()); - }) - .command('resend ', 'Resend an invitation', (yargs) => yargs.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()); - }) + .command( + 'list', + 'List invitations', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'get ', + 'Get an invitation', + (yargs) => yargs.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()); + }, + ) + .command( + 'send', + 'Send an invitation', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'revoke ', + 'Revoke an invitation', + (yargs) => yargs.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()); + }, + ) + .command( + 'resend ', + 'Resend an invitation', + (yargs) => yargs.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()); + }, + ) .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' } }) - .command('list ', 'List sessions for a user', (yargs) => yargs.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()); - }) - .command('revoke ', 'Revoke a session', (yargs) => yargs.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()); - }) + .command( + 'list ', + 'List sessions for a user', + (yargs) => + yargs + .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(), + ); + }, + ) + .command( + 'revoke ', + 'Revoke a session', + (yargs) => yargs.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()); + }, + ) .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' } }) - .command('list', 'List connections', (yargs) => yargs.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()); - }) - .command('get ', 'Get a connection', (yargs) => yargs.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()); - }) - .command('delete ', 'Delete a connection', (yargs) => yargs.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()); - }) + .command( + 'list', + 'List connections', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'get ', + 'Get a connection', + (yargs) => yargs.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()); + }, + ) + .command( + 'delete ', + 'Delete a connection', + (yargs) => + yargs + .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(), + ); + }, + ) .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' } }) - .command('list', 'List directories', (yargs) => yargs.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()); - }) - .command('get ', 'Get a directory', (yargs) => yargs.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()); - }) - .command('delete ', 'Delete a directory', (yargs) => yargs.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()); - }) - .command('list-users', 'List directory users', (yargs) => yargs.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()); - }) - .command('list-groups', 'List directory groups', (yargs) => yargs.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()); - }) + .command( + 'list', + 'List directories', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'get ', + 'Get a directory', + (yargs) => yargs.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()); + }, + ) + .command( + 'delete ', + 'Delete a directory', + (yargs) => + yargs + .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(), + ); + }, + ) + .command( + 'list-users', + 'List directory users', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'list-groups', + 'List directory groups', + (yargs) => + yargs.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(), + ); + }, + ) .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' } }) - .command('list', 'List events', (yargs) => yargs.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()); - }) + .command( + 'list', + 'List events', + (yargs) => + yargs.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(), + ); + }, + ) .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' } }) - .command('create-event ', 'Create an audit log event', (yargs) => yargs.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()); - }) - .command('export', 'Export audit logs', (yargs) => yargs.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()); - }) - .command('list-actions', 'List available audit log actions', (yargs) => yargs, 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()); - }) - .command('get-schema ', 'Get schema for an audit log action', (yargs) => yargs.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()); - }) - .command('create-schema ', 'Create an audit log schema', (yargs) => yargs.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()); - }) - .command('get-retention ', 'Get audit log retention period', (yargs) => yargs.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()); - }) + .command( + 'create-event ', + 'Create an audit log event', + (yargs) => + yargs + .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(), + ); + }, + ) + .command( + 'export', + 'Export audit logs', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'list-actions', + 'List available audit log actions', + (yargs) => yargs, + 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()); + }, + ) + .command( + 'get-schema ', + 'Get schema for an audit log action', + (yargs) => yargs.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()); + }, + ) + .command( + 'create-schema ', + 'Create an audit log schema', + (yargs) => + yargs + .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(), + ); + }, + ) + .command( + 'get-retention ', + 'Get audit log retention period', + (yargs) => yargs.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()); + }, + ) .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' } }) - .command('list', 'List feature flags', (yargs) => yargs.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()); - }) - .command('get ', 'Get a feature flag', (yargs) => yargs.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()); - }) - .command('enable ', 'Enable a feature flag', (yargs) => yargs.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()); - }) - .command('disable ', 'Disable a feature flag', (yargs) => yargs.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()); - }) - .command('add-target ', 'Add a target to a feature flag', (yargs) => yargs.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()); - }) - .command('remove-target ', 'Remove a target from a feature flag', (yargs) => yargs.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()); - }) + .command( + 'list', + 'List feature flags', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'get ', + 'Get a feature flag', + (yargs) => yargs.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()); + }, + ) + .command( + 'enable ', + 'Enable a feature flag', + (yargs) => yargs.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()); + }, + ) + .command( + 'disable ', + 'Disable a feature flag', + (yargs) => yargs.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()); + }, + ) + .command( + 'add-target ', + 'Add a target to a feature flag', + (yargs) => + yargs + .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(), + ); + }, + ) + .command( + 'remove-target ', + 'Remove a target from a feature flag', + (yargs) => + yargs + .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(), + ); + }, + ) .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' } }) - .command('list', 'List webhooks', (yargs) => yargs, 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()); - }) - .command('create', 'Create a webhook', (yargs) => yargs.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()); - }) - .command('delete ', 'Delete a webhook', (yargs) => yargs.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()); - }) + .command( + 'list', + 'List webhooks', + (yargs) => yargs, + 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()); + }, + ) + .command( + 'create', + 'Create a webhook', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'delete ', + 'Delete a webhook', + (yargs) => yargs.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()); + }, + ) .demandCommand(1, 'Please specify a webhook subcommand') .strict(), ) @@ -991,34 +1485,49 @@ yargs(rawArgs) .options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }) .command('redirect', 'Manage redirect URIs', (yargs) => yargs - .command('add ', 'Add a redirect URI', (yargs) => yargs.positional('uri', { type: 'string', demandOption: true }), async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runConfigRedirectAdd } = await import('./commands/config.js'); - await runConfigRedirectAdd(argv.uri, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); - }) + .command( + 'add ', + 'Add a redirect URI', + (yargs) => yargs.positional('uri', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConfigRedirectAdd } = await import('./commands/config.js'); + await runConfigRedirectAdd(argv.uri, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ) .demandCommand(1) .strict(), ) .command('cors', 'Manage CORS origins', (yargs) => yargs - .command('add ', 'Add a CORS origin', (yargs) => yargs.positional('origin', { type: 'string', demandOption: true }), async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runConfigCorsAdd } = await import('./commands/config.js'); - await runConfigCorsAdd(argv.origin, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); - }) + .command( + 'add ', + 'Add a CORS origin', + (yargs) => yargs.positional('origin', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConfigCorsAdd } = await import('./commands/config.js'); + await runConfigCorsAdd(argv.origin, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ) .demandCommand(1) .strict(), ) .command('homepage-url', 'Manage homepage URL', (yargs) => yargs - .command('set ', 'Set the homepage URL', (yargs) => yargs.positional('url', { type: 'string', demandOption: true }), async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runConfigHomepageUrlSet } = await import('./commands/config.js'); - await runConfigHomepageUrlSet(argv.url, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); - }) + .command( + 'set ', + 'Set the homepage URL', + (yargs) => yargs.positional('url', { type: 'string', demandOption: true }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConfigHomepageUrlSet } = await import('./commands/config.js'); + await runConfigHomepageUrlSet(argv.url, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + }, + ) .demandCommand(1) .strict(), ) @@ -1028,126 +1537,274 @@ yargs(rawArgs) .command('portal', 'Manage Admin Portal', (yargs) => yargs .options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }) - .command('generate-link', 'Generate an Admin Portal link', (yargs) => yargs.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()); - }) + .command( + 'generate-link', + 'Generate an Admin Portal link', + (yargs) => + yargs.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(), + ); + }, + ) .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' } }) - .command('list', 'List vault objects', (yargs) => yargs.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()); - }) - .command('get ', 'Get a vault object', (yargs) => yargs.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()); - }) - .command('get-by-name ', 'Get a vault object by name', (yargs) => yargs.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()); - }) - .command('create', 'Create a vault object', (yargs) => yargs.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()); - }) - .command('update ', 'Update a vault object', (yargs) => yargs.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()); - }) - .command('delete ', 'Delete a vault object', (yargs) => yargs.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()); - }) - .command('describe ', 'Describe a vault object', (yargs) => yargs.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()); - }) - .command('list-versions ', 'List vault object versions', (yargs) => yargs.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()); - }) + .command( + 'list', + 'List vault objects', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'get ', + 'Get a vault object', + (yargs) => yargs.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()); + }, + ) + .command( + 'get-by-name ', + 'Get a vault object by name', + (yargs) => yargs.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()); + }, + ) + .command( + 'create', + 'Create a vault object', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'update ', + 'Update a vault object', + (yargs) => + yargs + .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(), + ); + }, + ) + .command( + 'delete ', + 'Delete a vault object', + (yargs) => yargs.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()); + }, + ) + .command( + 'describe ', + 'Describe a vault object', + (yargs) => yargs.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()); + }, + ) + .command( + 'list-versions ', + 'List vault object versions', + (yargs) => yargs.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()); + }, + ) .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' } }) - .command('list', 'List API keys', (yargs) => yargs.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()); - }) - .command('create', 'Create an API key', (yargs) => yargs.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()); - }) - .command('validate ', 'Validate an API key', (yargs) => yargs.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()); - }) - .command('delete ', 'Delete an API key', (yargs) => yargs.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()); - }) + .command( + 'list', + 'List API keys', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'create', + 'Create an API key', + (yargs) => + yargs.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(), + ); + }, + ) + .command( + 'validate ', + 'Validate an API key', + (yargs) => yargs.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()); + }, + ) + .command( + 'delete ', + 'Delete an API key', + (yargs) => yargs.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()); + }, + ) .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' } }) - .command('get ', 'Get a domain', (yargs) => yargs.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()); - }) - .command('create ', 'Create a domain', (yargs) => yargs.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()); - }) - .command('verify ', 'Verify a domain', (yargs) => yargs.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()); - }) - .command('delete ', 'Delete a domain', (yargs) => yargs.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()); - }) + .command( + 'get ', + 'Get a domain', + (yargs) => yargs.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()); + }, + ) + .command( + 'create ', + 'Create a domain', + (yargs) => + yargs + .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()); + }, + ) + .command( + 'verify ', + 'Verify a domain', + (yargs) => yargs.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()); + }, + ) + .command( + 'delete ', + 'Delete a domain', + (yargs) => yargs.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()); + }, + ) .demandCommand(1, 'Please specify an org-domain subcommand') .strict(), ) @@ -1166,21 +1823,23 @@ yargs(rawArgs) 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()); + await runSeed( + { file: argv.file, clean: argv.clean }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); }, ) .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' }, - domain: { type: 'string', describe: 'Domain to add and verify' }, - roles: { type: 'string', describe: 'Comma-separated role slugs to create' }, - }), + yargs.positional('name', { type: 'string', demandOption: true, describe: 'Organization name' }).options({ + ...insecureStorageOption, + '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'); @@ -1196,15 +1855,13 @@ yargs(rawArgs) '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' }, - }), + 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'); diff --git a/src/commands/api-key-mgmt.ts b/src/commands/api-key-mgmt.ts index 43a94a2..a83f1a3 100644 --- a/src/commands/api-key-mgmt.ts +++ b/src/commands/api-key-mgmt.ts @@ -36,12 +36,7 @@ export async function runApiKeyList(options: ApiKeyListOptions, apiKey: string, return; } - const rows = result.data.map((key) => [ - key.id, - key.name, - key.obfuscatedValue ?? chalk.dim('-'), - key.createdAt, - ]); + 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), diff --git a/src/commands/debug-sso.ts b/src/commands/debug-sso.ts index 7ae9f38..3299824 100644 --- a/src/commands/debug-sso.ts +++ b/src/commands/debug-sso.ts @@ -24,7 +24,11 @@ export async function runDebugSso(connectionId: string, apiKey: string, baseUrl? 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[], + events: [ + 'authentication.email_verification_succeeded', + 'authentication.magic_auth_succeeded', + 'authentication.sso_succeeded', + ] as EventName[], ...(connection.organizationId && { organizationId: connection.organizationId }), limit: 5, }); diff --git a/src/commands/debug-sync.ts b/src/commands/debug-sync.ts index 33431f3..b1b4e1d 100644 --- a/src/commands/debug-sync.ts +++ b/src/commands/debug-sync.ts @@ -79,7 +79,9 @@ export async function runDebugSync(directoryId: string, apiKey: string, baseUrl? 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( + ` 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}`); diff --git a/src/commands/directory.ts b/src/commands/directory.ts index ac51da1..095304e 100644 --- a/src/commands/directory.ts +++ b/src/commands/directory.ts @@ -16,11 +16,7 @@ export interface DirectoryListOptions { order?: string; } -export async function runDirectoryList( - options: DirectoryListOptions, - apiKey: string, - baseUrl?: string, -): Promise { +export async function runDirectoryList(options: DirectoryListOptions, apiKey: string, baseUrl?: string): Promise { const client = createWorkOSClient(apiKey, baseUrl); try { 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.ts b/src/commands/invitation.ts index 21c0dcd..fd07fc7 100644 --- a/src/commands/invitation.ts +++ b/src/commands/invitation.ts @@ -52,13 +52,7 @@ export async function runInvitationList( console.log( formatTable( - [ - { header: 'ID' }, - { header: 'Email' }, - { header: 'Org ID' }, - { header: 'State' }, - { header: 'Expires At' }, - ], + [{ header: 'ID' }, { header: 'Email' }, { header: 'Org ID' }, { header: 'State' }, { header: 'Expires At' }], rows, ), ); diff --git a/src/commands/onboard-user.spec.ts b/src/commands/onboard-user.spec.ts index 24712fa..cabb2ac 100644 --- a/src/commands/onboard-user.spec.ts +++ b/src/commands/onboard-user.spec.ts @@ -44,9 +44,7 @@ describe('onboard-user command', () => { await runOnboardUser({ email: 'alice@acme.com', org: 'org_123', role: 'admin' }, 'sk_test'); - expect(mockSdk.userManagement.sendInvitation).toHaveBeenCalledWith( - expect.objectContaining({ roleSlug: 'admin' }), - ); + expect(mockSdk.userManagement.sendInvitation).toHaveBeenCalledWith(expect.objectContaining({ roleSlug: 'admin' })); }); describe('JSON mode', () => { diff --git a/src/commands/org-domain.spec.ts b/src/commands/org-domain.spec.ts index 8a6b25a..f65375f 100644 --- a/src/commands/org-domain.spec.ts +++ b/src/commands/org-domain.spec.ts @@ -15,9 +15,7 @@ vi.mock('../lib/workos-client.js', () => ({ const { setOutputMode } = await import('../utils/output.js'); -const { runOrgDomainGet, runOrgDomainCreate, runOrgDomainVerify, runOrgDomainDelete } = await import( - './org-domain.js' -); +const { runOrgDomainGet, runOrgDomainCreate, runOrgDomainVerify, runOrgDomainDelete } = await import('./org-domain.js'); const mockDomain = { id: 'org_domain_123', diff --git a/src/commands/permission.ts b/src/commands/permission.ts index a305fad..46af96a 100644 --- a/src/commands/permission.ts +++ b/src/commands/permission.ts @@ -46,10 +46,7 @@ export async function runPermissionList( ]); console.log( - formatTable( - [{ header: 'Slug' }, { header: 'Name' }, { header: 'Description' }, { header: 'Created' }], - rows, - ), + formatTable([{ header: 'Slug' }, { header: 'Name' }, { header: 'Description' }, { header: 'Created' }], rows), ); const { before, after } = result.listMetadata; diff --git a/src/commands/portal.spec.ts b/src/commands/portal.spec.ts index 910b6f9..7ea1cf9 100644 --- a/src/commands/portal.spec.ts +++ b/src/commands/portal.spec.ts @@ -53,7 +53,12 @@ describe('portal commands', () => { 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' }, + { + intent: 'dsync', + organization: 'org_123', + returnUrl: 'https://app.com/return', + successUrl: 'https://app.com/success', + }, 'sk_test', ); expect(mockSdk.portal.generateLink).toHaveBeenCalledWith( diff --git a/src/commands/role.ts b/src/commands/role.ts index e779d26..7206c7d 100644 --- a/src/commands/role.ts +++ b/src/commands/role.ts @@ -1,4 +1,3 @@ -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'; @@ -6,11 +5,7 @@ import { createApiErrorHandler } from '../lib/api-error-handler.js'; const handleApiError = createApiErrorHandler('Role'); -export async function runRoleList( - orgId: string | undefined, - apiKey: string, - baseUrl?: string, -): Promise { +export async function runRoleList(orgId: string | undefined, apiKey: string, baseUrl?: string): Promise { const client = createWorkOSClient(apiKey, baseUrl); try { @@ -38,13 +33,7 @@ export async function runRoleList( console.log( formatTable( - [ - { header: 'Slug' }, - { header: 'Name' }, - { header: 'Type' }, - { header: 'Permissions' }, - { header: 'Created' }, - ], + [{ header: 'Slug' }, { header: 'Name' }, { header: 'Type' }, { header: 'Permissions' }, { header: 'Created' }], rows, ), ); @@ -130,12 +119,7 @@ export async function runRoleUpdate( } } -export async function runRoleDelete( - slug: string, - orgId: string, - apiKey: string, - baseUrl?: string, -): Promise { +export async function runRoleDelete(slug: string, orgId: string, apiKey: string, baseUrl?: string): Promise { const client = createWorkOSClient(apiKey, baseUrl); try { diff --git a/src/commands/seed.ts b/src/commands/seed.ts index ca5ab2c..246b69f 100644 --- a/src/commands/seed.ts +++ b/src/commands/seed.ts @@ -122,8 +122,9 @@ export async function runSeed( 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 (error: unknown) { + 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}`)); } } @@ -166,10 +167,14 @@ export async function runSeed( console.log(chalk.dim(`State saved to ${STATE_FILE}`)); } } catch (error) { - // Partial failure — save what was created + // Partial failure — save what was created so --clean can tear down saveState(state); if (isJsonMode()) { - outputJson({ status: 'error', message: error instanceof Error ? error.message : 'Seed failed', partialState: state }); + outputJson({ + status: 'error', + message: error instanceof Error ? error.message : 'Seed failed', + partialState: state, + }); } else { console.error(chalk.red(`\nSeed failed: ${error instanceof Error ? error.message : 'Unknown error'}`)); console.log(chalk.dim(`Partial state saved to ${STATE_FILE}. Run \`workos seed --clean\` to tear down.`)); @@ -221,17 +226,16 @@ async function runSeedClean(apiKey: string, baseUrl?: string): Promise { outputSuccess('Seed cleanup complete', { stateFile: STATE_FILE }); } -async function applyConfig( - client: WorkOSCLIClient, - config: NonNullable, -): Promise { +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}`)); + console.log( + result.alreadyExists + ? chalk.dim(` Redirect URI exists: ${uri}`) + : chalk.green(` Added redirect URI: ${uri}`), + ); } } } @@ -240,9 +244,11 @@ async function applyConfig( 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}`)); + console.log( + result.alreadyExists + ? chalk.dim(` CORS origin exists: ${origin}`) + : chalk.green(` Added CORS origin: ${origin}`), + ); } } } diff --git a/src/commands/setup-org.spec.ts b/src/commands/setup-org.spec.ts index d7c4fa3..bc80de7 100644 --- a/src/commands/setup-org.spec.ts +++ b/src/commands/setup-org.spec.ts @@ -52,7 +52,10 @@ describe('setup-org command', () => { 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: 'admin', + name: 'admin', + }); }); it('generates portal link', async () => { diff --git a/src/commands/setup-org.ts b/src/commands/setup-org.ts index f2ec8d0..70583f7 100644 --- a/src/commands/setup-org.ts +++ b/src/commands/setup-org.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { createWorkOSClient } from '../lib/workos-client.js'; -import { outputJson, isJsonMode, exitWithError } from '../utils/output.js'; +import { outputJson, isJsonMode } from '../utils/output.js'; import { createApiErrorHandler } from '../lib/api-error-handler.js'; const handleApiError = createApiErrorHandler('SetupOrg'); @@ -87,7 +87,8 @@ export async function runSetupOrg(options: SetupOrgOptions, apiKey: string, base } 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 (options.domain) + console.log(` Domain: ${options.domain} (${summary.domainVerified ? 'verified' : 'pending'})`); if (summary.portalLink) console.log(` Portal: ${summary.portalLink}`); } } catch (error) { diff --git a/src/commands/vault.spec.ts b/src/commands/vault.spec.ts index c9f574a..4d38419 100644 --- a/src/commands/vault.spec.ts +++ b/src/commands/vault.spec.ts @@ -32,7 +32,15 @@ const { 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' }; +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[]; @@ -66,9 +74,7 @@ describe('vault commands', () => { listMetadata: { before: null, after: null }, }); await runVaultList({ limit: 10, order: 'asc' }, 'sk_test'); - expect(mockSdk.vault.listObjects).toHaveBeenCalledWith( - expect.objectContaining({ limit: 10, order: 'asc' }), - ); + expect(mockSdk.vault.listObjects).toHaveBeenCalledWith(expect.objectContaining({ limit: 10, order: 'asc' })); }); it('handles empty results', async () => { @@ -131,7 +137,11 @@ describe('vault commands', () => { 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' }); + expect(mockSdk.vault.updateObject).toHaveBeenCalledWith({ + id: 'obj_123', + value: 'new-value', + versionCheck: 'v1', + }); }); }); diff --git a/src/commands/vault.ts b/src/commands/vault.ts index 9d99ce5..19d18d8 100644 --- a/src/commands/vault.ts +++ b/src/commands/vault.ts @@ -34,11 +34,7 @@ export async function runVaultList(options: VaultListOptions, apiKey: string, ba return; } - const rows = result.data.map((obj) => [ - obj.id, - obj.name, - obj.updatedAt ? String(obj.updatedAt) : chalk.dim('-'), - ]); + 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)); diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts index 79bdca9..2ed701f 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -29,12 +29,7 @@ export async function runWebhookList(apiKey: string, baseUrl?: string): Promise< return; } - const rows = result.data.map((ep) => [ - ep.id, - ep.url, - ep.events.join(', '), - ep.created_at, - ]); + 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)); @@ -51,12 +46,7 @@ export async function runWebhookList(apiKey: string, baseUrl?: string): Promise< } } -export async function runWebhookCreate( - url: string, - events: string[], - apiKey: string, - baseUrl?: string, -): Promise { +export async function runWebhookCreate(url: string, events: string[], apiKey: string, baseUrl?: string): Promise { const client = createWorkOSClient(apiKey, baseUrl); try { diff --git a/src/lib/api-error-handler.spec.ts b/src/lib/api-error-handler.spec.ts index 5b16268..933f4c4 100644 --- a/src/lib/api-error-handler.spec.ts +++ b/src/lib/api-error-handler.spec.ts @@ -46,7 +46,12 @@ describe('createApiErrorHandler', () => { it('handles 422 with validation errors', () => { const handler = createApiErrorHandler('Organization'); - handler(new WorkOSApiError('Validation failed', 422, undefined, [{ message: 'Name is required' }, { message: 'Domain invalid' }])); + handler( + new WorkOSApiError('Validation failed', 422, undefined, [ + { message: 'Name is required' }, + { message: 'Domain invalid' }, + ]), + ); expect(parseError().error.message).toBe('Name is required, Domain invalid'); }); @@ -64,8 +69,17 @@ describe('createApiErrorHandler', () => { }); 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 }> }; + 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; diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 39249e0..6d46bc4 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -352,16 +352,77 @@ const commands: CommandSchema[] = [ { 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 }], + 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: '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 }, + ], + }, ], }, { @@ -370,10 +431,40 @@ const commands: CommandSchema[] = [ 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: '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 }], + }, ], }, { @@ -381,13 +472,50 @@ const commands: CommandSchema[] = [ 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: '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 }], + }, ], }, { @@ -395,11 +523,46 @@ const commands: CommandSchema[] = [ 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: '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 }], + }, ], }, { @@ -407,8 +570,17 @@ const commands: CommandSchema[] = [ 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: '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 }], + }, ], }, { @@ -416,9 +588,35 @@ const commands: CommandSchema[] = [ 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: '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, + }, + ], + }, ], }, { @@ -426,11 +624,51 @@ const commands: CommandSchema[] = [ 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: '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, + ], + }, ], }, { @@ -438,7 +676,29 @@ const commands: CommandSchema[] = [ 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: '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, + ], + }, ], }, { @@ -446,12 +706,45 @@ const commands: CommandSchema[] = [ 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: '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: '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 }], + }, ], }, { @@ -460,11 +753,37 @@ const commands: CommandSchema[] = [ 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: '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 }, + ], + }, ], }, { @@ -473,8 +792,19 @@ const commands: CommandSchema[] = [ 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: '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 }], + }, ], }, { @@ -482,9 +812,39 @@ const commands: CommandSchema[] = [ 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: '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 }], + }, + ], + }, ], }, { @@ -492,7 +852,28 @@ const commands: CommandSchema[] = [ 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: '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 }, + ], + }, ], }, { @@ -501,13 +882,49 @@ const commands: CommandSchema[] = [ 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: '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 }], + }, ], }, { @@ -515,10 +932,39 @@ const commands: CommandSchema[] = [ 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: '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 }], + }, ], }, { @@ -526,18 +972,89 @@ const commands: CommandSchema[] = [ 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 }] }, + { + 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: '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/output.spec.ts b/src/utils/output.spec.ts index 17fb5b7..43296cb 100644 --- a/src/utils/output.spec.ts +++ b/src/utils/output.spec.ts @@ -81,7 +81,6 @@ describe('output', () => { expect(spy).toHaveBeenCalledWith('[1,2,3]'); spy.mockRestore(); }); - }); describe('outputError', () => { From eadc29ad339d2fbc2a1189a3ccbcff18a9cce78b Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 2 Mar 2026 18:22:30 -0600 Subject: [PATCH 09/16] fix: use exitWithError in seed partial failure path Replace manual console.error + process.exit(1) with exitWithError() for consistent structured error output. Partial state passed via the details field. --- src/commands/seed.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/commands/seed.ts b/src/commands/seed.ts index 246b69f..bc38742 100644 --- a/src/commands/seed.ts +++ b/src/commands/seed.ts @@ -169,17 +169,11 @@ export async function runSeed( } catch (error) { // Partial failure — save what was created so --clean can tear down saveState(state); - if (isJsonMode()) { - outputJson({ - status: 'error', - message: error instanceof Error ? error.message : 'Seed failed', - partialState: state, - }); - } else { - console.error(chalk.red(`\nSeed failed: ${error instanceof Error ? error.message : 'Unknown error'}`)); - console.log(chalk.dim(`Partial state saved to ${STATE_FILE}. Run \`workos seed --clean\` to tear down.`)); - } - process.exit(1); + 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, + }); } } From ca598e4a290c2a380a6429d1caf5ca3d3ffcca11 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 2 Mar 2026 18:23:24 -0600 Subject: [PATCH 10/16] chore: formatting --- src/bin.ts | 50 ++++++++++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 2a1c00f..844b462 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -608,13 +608,11 @@ yargs(rawArgs) 'set-permissions ', 'Set all permissions on a role (replaces existing)', (yargs) => - yargs - .positional('slug', { type: 'string', demandOption: true }) - .option('permissions', { - type: 'string', - demandOption: true, - describe: 'Comma-separated permission slugs', - }), + yargs.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'); @@ -968,14 +966,12 @@ yargs(rawArgs) 'list ', 'List sessions for a user', (yargs) => - yargs - .positional('userId', { type: 'string', demandOption: true }) - .options({ - limit: { type: 'number' }, - before: { type: 'string' }, - after: { type: 'string' }, - order: { type: 'string' }, - }), + yargs.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'); @@ -1212,19 +1208,17 @@ yargs(rawArgs) 'create-event ', 'Create an audit log event', (yargs) => - yargs - .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' }, - }), + yargs.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'); From 68aac7541ae6e77c80faa20d56f85aac0938f784 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 3 Mar 2026 13:53:53 -0600 Subject: [PATCH 11/16] feat: enrich subcommand help with required options Add registerSubcommand() helper that introspects yargs builders to discover demandedOptions and appends them to usage strings. Refactor all 19 command group builders in bin.ts to use the helper, so parent help now shows required args inline (e.g. `generate-link --intent --org `). --- src/bin.ts | 2640 +++++++++++-------------- src/utils/register-subcommand.spec.ts | 149 ++ src/utils/register-subcommand.ts | 55 + 3 files changed, 1326 insertions(+), 1518 deletions(-) create mode 100644 src/utils/register-subcommand.spec.ts create mode 100644 src/utils/register-subcommand.ts diff --git a/src/bin.ts b/src/bin.ts index 844b462..c59e4b5 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,1536 +273,1139 @@ 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' }), - 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()); - }, - ) - .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)' }, - }), - 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(), - ); - }, - ) - .command( - 'delete ', - 'Delete an organization', - (yargs) => yargs.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()); - }, - ) - .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_*', - }, - }) - .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(), - ) - // --- 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)' }, - }) - .command( - 'list', - 'List roles', - (yargs) => yargs, - 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()); - }, - ) - .command( - 'get ', - 'Get a role by slug', - (yargs) => yargs.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()); - }, - ) - .command( - 'create', - 'Create a role', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'update ', - 'Update a role', - (yargs) => - yargs - .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(), - ); - }, - ) - .command( - 'delete ', - 'Delete an org-scoped role (requires --org)', - (yargs) => yargs.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()); - }, - ) - .command( - 'set-permissions ', - 'Set all permissions on a role (replaces existing)', - (yargs) => - yargs.positional('slug', { type: 'string', demandOption: true }).option('permissions', { + } + 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', - 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(), - ); - }, - ) - .command( - 'add-permission ', - 'Add a permission to a role', - (yargs) => - yargs - .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(), - ); - }, - ) - .command( - 'remove-permission ', - 'Remove a permission from an org role (requires --org)', - (yargs) => - yargs - .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(), - ); - }, - ) - .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' } }) - .command( - 'list', - 'List permissions', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'get ', - 'Get a permission', - (yargs) => yargs.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()); - }, - ) - .command( - 'create', - 'Create a permission', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'update ', - 'Update a permission', - (yargs) => - yargs - .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(), - ); - }, - ) - .command( - 'delete ', - 'Delete a permission', - (yargs) => yargs.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()); - }, - ) - .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' } }) - .command( - 'list', - 'List memberships', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'get ', - 'Get a membership', - (yargs) => yargs.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()); - }, - ) - .command( - 'create', - 'Create a membership', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'update ', - 'Update a membership', - (yargs) => yargs.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()); - }, - ) - .command( - 'delete ', - 'Delete a membership', - (yargs) => yargs.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()); - }, - ) - .command( - 'deactivate ', - 'Deactivate a membership', - (yargs) => yargs.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()); - }, - ) - .command( - 'reactivate ', - 'Reactivate a membership', - (yargs) => yargs.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()); - }, - ) - .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' } }) - .command( - 'list', - 'List invitations', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'get ', - 'Get an invitation', - (yargs) => yargs.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()); - }, - ) - .command( - 'send', - 'Send an invitation', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'revoke ', - 'Revoke an invitation', - (yargs) => yargs.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()); - }, - ) - .command( - 'resend ', - 'Resend an invitation', - (yargs) => yargs.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()); - }, - ) - .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' } }) - .command( - 'list ', - 'List sessions for a user', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'revoke ', - 'Revoke a session', - (yargs) => yargs.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()); - }, - ) - .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' } }) - .command( - 'list', - 'List connections', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'get ', - 'Get a connection', - (yargs) => yargs.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()); - }, - ) - .command( - 'delete ', - 'Delete a connection', - (yargs) => - yargs - .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(), - ); - }, - ) - .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' } }) - .command( - 'list', - 'List directories', - (yargs) => - yargs.options({ - org: { type: 'string' }, - limit: { type: 'number' }, - before: { type: 'string' }, - after: { type: 'string' }, - order: { 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 { 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(), - ); - }, - ) - .command( - 'get ', - 'Get a directory', - (yargs) => yargs.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()); - }, - ) - .command( - 'delete ', - 'Delete a directory', - (yargs) => - yargs - .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(), - ); - }, - ) - .command( - 'list-users', - 'List directory users', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'list-groups', - 'List directory groups', - (yargs) => - yargs.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(), - ); - }, - ) - .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' } }) - .command( - 'list', - 'List events', - (yargs) => - yargs.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(), - ); - }, - ) - .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' } }) - .command( - 'create-event ', - 'Create an audit log event', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'export', - 'Export audit logs', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'list-actions', - 'List available audit log actions', - (yargs) => yargs, - 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()); - }, - ) - .command( - 'get-schema ', - 'Get schema for an audit log action', - (yargs) => yargs.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()); - }, - ) - .command( - 'create-schema ', - 'Create an audit log schema', - (yargs) => - yargs - .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(), - ); - }, - ) - .command( - 'get-retention ', - 'Get audit log retention period', - (yargs) => yargs.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()); - }, - ) - .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' } }) - .command( - 'list', - 'List feature flags', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'get ', - 'Get a feature flag', - (yargs) => yargs.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()); - }, - ) - .command( - 'enable ', - 'Enable a feature flag', - (yargs) => yargs.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()); - }, - ) - .command( - 'disable ', - 'Disable a feature flag', - (yargs) => yargs.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()); - }, - ) - .command( - 'add-target ', - 'Add a target to a feature flag', - (yargs) => - yargs - .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(), - ); - }, - ) - .command( - 'remove-target ', - 'Remove a target from a feature flag', - (yargs) => - yargs - .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(), - ); - }, - ) - .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' } }) - .command( - 'list', - 'List webhooks', - (yargs) => yargs, - 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()); - }, - ) - .command( - 'create', - 'Create a webhook', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'delete ', - 'Delete a webhook', - (yargs) => yargs.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()); - }, - ) - .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' } }) - .command('redirect', 'Manage redirect URIs', (yargs) => - yargs - .command( - 'add ', - 'Add a redirect URI', - (yargs) => yargs.positional('uri', { type: 'string', demandOption: true }), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runConfigRedirectAdd } = await import('./commands/config.js'); - await runConfigRedirectAdd(argv.uri, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); - }, - ) - .demandCommand(1) - .strict(), - ) - .command('cors', 'Manage CORS origins', (yargs) => - yargs - .command( - 'add ', - 'Add a CORS origin', - (yargs) => yargs.positional('origin', { type: 'string', demandOption: true }), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runConfigCorsAdd } = await import('./commands/config.js'); - await runConfigCorsAdd(argv.origin, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); - }, - ) - .demandCommand(1) - .strict(), - ) - .command('homepage-url', 'Manage homepage URL', (yargs) => - yargs - .command( - 'set ', - 'Set the homepage URL', - (yargs) => yargs.positional('url', { type: 'string', demandOption: true }), - async (argv) => { - await applyInsecureStorage(argv.insecureStorage); - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); - const { runConfigHomepageUrlSet } = await import('./commands/config.js'); - await runConfigHomepageUrlSet(argv.url, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); - }, - ) - .demandCommand(1) - .strict(), - ) - .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' } }) - .command( - 'generate-link', - 'Generate an Admin Portal link', - (yargs) => - yargs.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(), - ); - }, - ) - .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' } }) - .command( - 'list', - 'List vault objects', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'get ', - 'Get a vault object', - (yargs) => yargs.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()); - }, - ) - .command( - 'get-by-name ', - 'Get a vault object by name', - (yargs) => yargs.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()); - }, - ) - .command( - 'create', - 'Create a vault object', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'update ', - 'Update a vault object', - (yargs) => - yargs - .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(), - ); - }, - ) - .command( - 'delete ', - 'Delete a vault object', - (yargs) => yargs.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()); - }, - ) - .command( - 'describe ', - 'Describe a vault object', - (yargs) => yargs.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()); - }, - ) - .command( - 'list-versions ', - 'List vault object versions', - (yargs) => yargs.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()); - }, - ) - .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' } }) - .command( - 'list', - 'List API keys', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'create', - 'Create an API key', - (yargs) => - yargs.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(), - ); - }, - ) - .command( - 'validate ', - 'Validate an API key', - (yargs) => yargs.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()); - }, - ) - .command( - 'delete ', - 'Delete an API key', - (yargs) => yargs.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()); - }, - ) - .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' } }) - .command( - 'get ', - 'Get a domain', - (yargs) => yargs.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()); - }, - ) - .command( - 'create ', - 'Create a domain', - (yargs) => - yargs - .positional('domain', { type: 'string', demandOption: true }) - .option('org', { type: 'string', demandOption: true }), - async (argv) => { + 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 { runOrgDomainCreate } = await import('./commands/org-domain.js'); - await runOrgDomainCreate(argv.domain, argv.org, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + const { runConfigRedirectAdd } = await import('./commands/config.js'); + await runConfigRedirectAdd(argv.uri, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, - ) - .command( - 'verify ', - 'Verify a domain', - (yargs) => yargs.positional('id', { type: 'string', demandOption: true }), - async (argv) => { + ); + 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 { runOrgDomainVerify } = await import('./commands/org-domain.js'); - await runOrgDomainVerify(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + const { runConfigCorsAdd } = await import('./commands/config.js'); + await runConfigCorsAdd(argv.origin, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, - ) - .command( - 'delete ', - 'Delete a domain', - (yargs) => yargs.positional('id', { type: 'string', demandOption: true }), - async (argv) => { + ); + 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 { runOrgDomainDelete } = await import('./commands/org-domain.js'); - await runOrgDomainDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + const { runConfigHomepageUrlSet } = await import('./commands/config.js'); + await runConfigHomepageUrlSet(argv.url, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, - ) - .demandCommand(1, 'Please specify an org-domain 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', diff --git a/src/utils/register-subcommand.spec.ts b/src/utils/register-subcommand.spec.ts new file mode 100644 index 0000000..0cfae0e --- /dev/null +++ b/src/utils/register-subcommand.spec.ts @@ -0,0 +1,149 @@ +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..5fc3fba --- /dev/null +++ b/src/utils/register-subcommand.ts @@ -0,0 +1,55 @@ +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); +} From e70641fbadd1a0c4e950d300fb8b5961d5c104dd Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 3 Mar 2026 15:44:28 -0600 Subject: [PATCH 12/16] test: expand spec coverage for debug, seed, and onboard commands --- src/commands/debug-sso.spec.ts | 133 ++++++++++++++------ src/commands/debug-sync.spec.ts | 186 ++++++++++++++++++++-------- src/commands/onboard-user.spec.ts | 80 +++++++++++- src/commands/seed.spec.ts | 198 +++++++++++++++++++++++++----- src/commands/setup-org.spec.ts | 92 +++++++++++++- 5 files changed, 565 insertions(+), 124 deletions(-) diff --git a/src/commands/debug-sso.spec.ts b/src/commands/debug-sso.spec.ts index 348fc5a..969436e 100644 --- a/src/commands/debug-sso.spec.ts +++ b/src/commands/debug-sso.spec.ts @@ -25,33 +25,44 @@ describe('debug-sso command', () => { 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({ - id: 'conn_123', - name: 'Okta SSO', - type: 'OktaSAML', - state: 'active', - organizationId: 'org_123', - createdAt: '2024-01-01', - }); + 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('active'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('OktaSAML'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('org_123'))).toBe(true); }); - it('identifies inactive connection', async () => { - mockSdk.sso.getConnection.mockResolvedValue({ - id: 'conn_123', - name: 'Broken SSO', - type: 'OktaSAML', - state: 'inactive', - organizationId: 'org_123', - createdAt: '2024-01-01', - }); + 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'); @@ -60,44 +71,94 @@ describe('debug-sso command', () => { }); it('shows recent auth events', async () => { - mockSdk.sso.getConnection.mockResolvedValue({ - id: 'conn_123', - name: 'SSO', - type: 'OktaSAML', - state: 'active', - organizationId: null, - createdAt: '2024-01-01', - }); + mockSdk.sso.getConnection.mockResolvedValue(activeConnection); mockSdk.events.listEvents.mockResolvedValue({ - data: [{ id: 'evt_1', event: 'authentication.sso_succeeded', createdAt: '2024-01-02' }], + 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 full diagnosis as JSON', async () => { - mockSdk.sso.getConnection.mockResolvedValue({ - id: 'conn_123', - name: 'SSO', - type: 'OktaSAML', - state: 'inactive', - organizationId: 'org_123', - createdAt: '2024-01-01', - }); + 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-sync.spec.ts b/src/commands/debug-sync.spec.ts index a4335d3..8bec4a1 100644 --- a/src/commands/debug-sync.spec.ts +++ b/src/commands/debug-sync.spec.ts @@ -29,87 +29,171 @@ describe('debug-sync command', () => { afterEach(() => vi.restoreAllMocks()); - it('displays directory details with user/group counts', async () => { - mockSdk.directorySync.getDirectory.mockResolvedValue({ - id: 'dir_123', - name: 'Okta SCIM', - type: 'okta scim v2.0', - state: 'linked', - organizationId: 'org_123', - createdAt: '2024-01-01', - }); - mockSdk.directorySync.listUsers.mockResolvedValue({ data: [{ id: 'u1' }], listMetadata: { after: null } }); - mockSdk.directorySync.listGroups.mockResolvedValue({ data: [{ id: 'g1' }], listMetadata: { after: null } }); - mockSdk.events.listEvents.mockResolvedValue({ - data: [{ id: 'evt_1', event: 'dsync.user.created', createdAt: '2024-01-02' }], - listMetadata: {}, - }); + 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('linked'))).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('identifies unlinked directory', async () => { - mockSdk.directorySync.getDirectory.mockResolvedValue({ - id: 'dir_123', - name: 'Broken Dir', - type: 'okta scim v2.0', - state: 'unlinked', - organizationId: null, - createdAt: '2024-01-01', - }); - mockSdk.directorySync.listUsers.mockResolvedValue({ data: [], listMetadata: { after: null } }); + 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'); - expect(consoleOutput.some((l) => l.includes('not linked'))).toBe(true); + // Should still complete + expect(consoleOutput.some((l) => l.includes('Okta SCIM'))).toBe(true); }); - it('warns when no sync events found', async () => { - mockSdk.directorySync.getDirectory.mockResolvedValue({ - id: 'dir_123', - name: 'Dir', - type: 'okta scim v2.0', - state: 'linked', - organizationId: null, - createdAt: '2024-01-01', - }); + 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.mockResolvedValue({ data: [], listMetadata: {} }); + mockSdk.events.listEvents.mockRejectedValue(new Error('Events not available')); await runDebugSync('dir_123', 'sk_test'); - expect(consoleOutput.some((l) => l.includes('stalled'))).toBe(true); + 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 full diagnosis as JSON', async () => { - mockSdk.directorySync.getDirectory.mockResolvedValue({ - id: 'dir_123', - name: 'Dir', - type: 'okta scim v2.0', - state: 'unlinked', - organizationId: null, - createdAt: '2024-01-01', - }); - mockSdk.directorySync.listUsers.mockResolvedValue({ data: [], listMetadata: { after: null } }); - mockSdk.directorySync.listGroups.mockResolvedValue({ data: [], listMetadata: { after: null } }); - mockSdk.events.listEvents.mockResolvedValue({ data: [], listMetadata: {} }); + 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.issues.length).toBeGreaterThan(0); + 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/onboard-user.spec.ts b/src/commands/onboard-user.spec.ts index cabb2ac..c90902f 100644 --- a/src/commands/onboard-user.spec.ts +++ b/src/commands/onboard-user.spec.ts @@ -19,13 +19,17 @@ describe('onboard-user command', () => { beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); }); }); - afterEach(() => vi.restoreAllMocks()); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); it('sends invitation with email and org', async () => { mockSdk.userManagement.sendInvitation.mockResolvedValue({ id: 'inv_123', state: 'pending' }); @@ -44,14 +48,72 @@ describe('onboard-user command', () => { await runOnboardUser({ email: 'alice@acme.com', org: 'org_123', role: 'admin' }, 'sk_test'); - expect(mockSdk.userManagement.sendInvitation).toHaveBeenCalledWith(expect.objectContaining({ roleSlug: 'admin' })); + 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', async () => { + 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'); @@ -60,5 +122,17 @@ describe('onboard-user command', () => { 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/seed.spec.ts b/src/commands/seed.spec.ts index 38e6eaf..91b8e38 100644 --- a/src/commands/seed.spec.ts +++ b/src/commands/seed.spec.ts @@ -39,16 +39,21 @@ const mockReadFileSync = vi.mocked(readFileSync); const mockWriteFileSync = vi.mocked(writeFileSync); const mockUnlinkSync = vi.mocked(unlinkSync); -const SEED_YAML = ` +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"] @@ -72,14 +77,12 @@ describe('seed command', () => { }); }); - afterEach(() => { - vi.restoreAllMocks(); - }); + afterEach(() => vi.restoreAllMocks()); describe('runSeed with --file', () => { - it('creates resources in dependency order', async () => { + it('creates resources in dependency order: permissions → roles → orgs → config', async () => { mockExistsSync.mockReturnValue(true); - mockReadFileSync.mockReturnValue(SEED_YAML); + mockReadFileSync.mockReturnValue(FULL_SEED_YAML); mockSdk.authorization.createPermission.mockResolvedValue({ slug: 'read-users' }); mockSdk.authorization.createEnvironmentRole.mockResolvedValue({ slug: 'admin' }); mockSdk.authorization.setEnvironmentRolePermissions.mockResolvedValue({}); @@ -90,22 +93,21 @@ describe('seed command', () => { await runSeed({ file: 'workos-seed.yml' }, 'sk_test'); - // Verify order: permissions first - expect(mockSdk.authorization.createPermission).toHaveBeenCalledWith( - expect.objectContaining({ slug: 'read-users' }), - ); + // 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).toHaveBeenCalledWith( - expect.objectContaining({ slug: 'admin' }), - ); - // Then permission assignment - expect(mockSdk.authorization.setEnvironmentRolePermissions).toHaveBeenCalledWith('admin', { - permissions: ['read-users'], - }); + 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' }), - ); + 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'); @@ -113,10 +115,13 @@ describe('seed command', () => { // State file written expect(mockWriteFileSync).toHaveBeenCalled(); - expect(consoleOutput.some((l) => l.includes('Seed complete'))).toBe(true); + 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 resources', async () => { + it('skips already-existing permissions without failing', async () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(` permissions: @@ -127,9 +132,83 @@ permissions: 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); @@ -137,14 +216,29 @@ permissions: 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', async () => { + it('deletes resources in reverse order: orgs → permissions', async () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue( JSON.stringify({ - permissions: [{ slug: 'read-users' }], + permissions: [{ slug: 'read-users' }, { slug: 'write-users' }], roles: [{ slug: 'admin' }], organizations: [{ id: 'org_123', name: 'Test Org' }], createdAt: '2024-01-01', @@ -155,11 +249,45 @@ permissions: await runSeed({ clean: true }, 'sk_test'); - // Orgs deleted first (reverse of creation order) expect(mockSdk.organizations.deleteOrganization).toHaveBeenCalledWith('org_123'); - // Permissions deleted + expect(mockSdk.authorization.deletePermission).toHaveBeenCalledWith('write-users'); expect(mockSdk.authorization.deletePermission).toHaveBeenCalledWith('read-users'); - // State file removed + 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(); }); @@ -176,7 +304,7 @@ permissions: beforeEach(() => setOutputMode('json')); afterEach(() => setOutputMode('human')); - it('outputs JSON status on success', async () => { + it('outputs JSON status with state on seed success', async () => { mockExistsSync.mockReturnValue(true); mockReadFileSync.mockReturnValue(` permissions: @@ -189,7 +317,21 @@ permissions: 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/setup-org.spec.ts b/src/commands/setup-org.spec.ts index bc80de7..7a68625 100644 --- a/src/commands/setup-org.spec.ts +++ b/src/commands/setup-org.spec.ts @@ -27,13 +27,20 @@ describe('setup-org command', () => { afterEach(() => vi.restoreAllMocks()); - it('creates org with name', async () => { + 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' }); @@ -43,6 +50,17 @@ describe('setup-org command', () => { 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 () => { @@ -52,10 +70,26 @@ describe('setup-org command', () => { 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: '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 () => { @@ -65,13 +99,36 @@ describe('setup-org command', () => { 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', async () => { + 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' }); @@ -80,6 +137,29 @@ describe('setup-org command', () => { 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'); }); }); }); From c036ef98f09d6e0fc9f0487564802b4c3b4e03ea Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 3 Mar 2026 15:45:07 -0600 Subject: [PATCH 13/16] chore: formatting --- src/bin.ts | 835 +++++++++++++++++++------- src/commands/debug-sso.spec.ts | 4 +- src/commands/debug-sync.spec.ts | 56 +- src/commands/onboard-user.spec.ts | 4 +- src/commands/seed.spec.ts | 20 +- src/commands/setup-org.spec.ts | 10 +- src/utils/register-subcommand.spec.ts | 22 +- src/utils/register-subcommand.ts | 4 +- 8 files changed, 711 insertions(+), 244 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index c59e4b5..87c1b24 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -777,43 +777,74 @@ yargs(rawArgs) }) .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) => { + 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(), + { + 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) => { + 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) => { + 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(), + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), ); }, ); - registerSubcommand(yargs, 'update ', 'Update a membership', + registerSubcommand( + yargs, + 'update ', + 'Update a membership', (y) => y.positional('id', { type: 'string', demandOption: true }).option('role', { type: 'string' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); @@ -822,24 +853,36 @@ yargs(rawArgs) 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) => { + 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) => { + 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) => { + 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'); @@ -850,52 +893,89 @@ yargs(rawArgs) }) .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) => { + 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(), + { + 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) => { + 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) => { + 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(), + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), ); }, ); - registerSubcommand(yargs, 'revoke ', 'Revoke an invitation', - (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { + 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) => { + 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'); @@ -906,22 +986,35 @@ yargs(rawArgs) }) .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) => { + 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(), + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), ); }, ); - registerSubcommand(yargs, 'revoke ', 'Revoke a session', - (y) => y.positional('sessionId', { type: 'string', demandOption: true }), async (argv) => { + 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'); @@ -932,91 +1025,166 @@ yargs(rawArgs) }) .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) => { + 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(), + { + 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) => { + 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 }), + 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()); + 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' } }), + 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(), + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), ); }, ); - registerSubcommand(yargs, 'get ', 'Get a directory', - (y) => y.positional('id', { type: 'string', demandOption: true }), async (argv) => { + 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 }), + 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()); + 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' } }), + 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(), + 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' } }), + 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(), + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), ); }, ); @@ -1024,18 +1192,34 @@ yargs(rawArgs) }) .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) => { + 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(), + { + events: argv.events.split(','), + after: argv.after, + organizationId: argv.org, + rangeStart: argv.rangeStart, + rangeEnd: argv.rangeEnd, + limit: argv.limit, + }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), ); }, ); @@ -1043,62 +1227,127 @@ yargs(rawArgs) }) .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) => { + 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()); + 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) => { + 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()); + 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) => { + 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 }), + 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()); + 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) => { + 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'); @@ -1109,83 +1358,147 @@ yargs(rawArgs) }) .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' } }), + 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(), + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), ); }, ); - registerSubcommand(yargs, 'get ', 'Get a feature flag', - (y) => y.positional('slug', { type: 'string', demandOption: true }), async (argv) => { + 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) => { + 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) => { + 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 }), + 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()); + 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 }), + 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()); + 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) => { + 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()); + 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) => { + 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'); @@ -1197,8 +1510,12 @@ yargs(rawArgs) .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) => { + 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 { runConfigRedirectAdd } = await import('./commands/config.js'); @@ -1208,8 +1525,12 @@ yargs(rawArgs) 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) => { + 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 { runConfigCorsAdd } = await import('./commands/config.js'); @@ -1219,8 +1540,12 @@ yargs(rawArgs) 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) => { + 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 { runConfigHomepageUrlSet } = await import('./commands/config.js'); @@ -1233,18 +1558,29 @@ yargs(rawArgs) }) .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) => { + 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(), + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), ); }, ); @@ -1252,67 +1588,122 @@ yargs(rawArgs) }) .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' } }), + 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()); + 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) => { + 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) => { + 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' } }), + 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()); + 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' } }), + 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()); + 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) => { + 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) => { + 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) => { + 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'); @@ -1323,44 +1714,68 @@ yargs(rawArgs) }) .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) => { + 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(), + 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) => { + 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(), + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), ); }, ); - registerSubcommand(yargs, 'validate ', 'Validate an API key', - (y) => y.positional('value', { type: 'string', demandOption: true }), async (argv) => { + 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) => { + 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'); @@ -1371,16 +1786,26 @@ yargs(rawArgs) }) .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) => { + 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 }), + 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'); @@ -1388,16 +1813,24 @@ yargs(rawArgs) 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) => { + 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) => { + 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'); diff --git a/src/commands/debug-sso.spec.ts b/src/commands/debug-sso.spec.ts index 969436e..6c4b102 100644 --- a/src/commands/debug-sso.spec.ts +++ b/src/commands/debug-sso.spec.ts @@ -103,9 +103,7 @@ describe('debug-sso command', () => { await runDebugSso('conn_123', 'sk_test'); - expect(mockSdk.events.listEvents).toHaveBeenCalledWith( - expect.objectContaining({ organizationId: 'org_123' }), - ); + expect(mockSdk.events.listEvents).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 'org_123' })); }); it('does not filter by org when connection has no organizationId', async () => { diff --git a/src/commands/debug-sync.spec.ts b/src/commands/debug-sync.spec.ts index 8bec4a1..67acd4c 100644 --- a/src/commands/debug-sync.spec.ts +++ b/src/commands/debug-sync.spec.ts @@ -45,17 +45,29 @@ describe('debug-sync command', () => { organizationId: null, }; - function mockCountsAndEvents(opts?: { users?: number; hasMore?: boolean; groups?: number; events?: Array<{ id: string; event: string; createdAt: string }> }) { + 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.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' }] }); + mockCountsAndEvents({ + users: 1, + groups: 1, + events: [{ id: 'evt_1', event: 'dsync.user.created', createdAt: '2024-01-02' }], + }); await runDebugSync('dir_123', 'sk_test'); @@ -66,7 +78,11 @@ describe('debug-sync command', () => { 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' }] }); + mockCountsAndEvents({ + users: 1, + groups: 1, + events: [{ id: 'e', event: 'dsync.user.created', createdAt: '2024-01-01' }], + }); await runDebugSync('dir_123', 'sk_test'); @@ -76,7 +92,11 @@ describe('debug-sync command', () => { 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' }] }); + mockCountsAndEvents({ + users: 1, + hasMore: true, + events: [{ id: 'e', event: 'dsync.user.created', createdAt: '2024-01-01' }], + }); await runDebugSync('dir_123', 'sk_test'); @@ -112,10 +132,12 @@ describe('debug-sync command', () => { 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' }, - ] }); + 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'); @@ -152,9 +174,7 @@ describe('debug-sync command', () => { await runDebugSync('dir_123', 'sk_test'); - expect(mockSdk.events.listEvents).toHaveBeenCalledWith( - expect.objectContaining({ organizationId: 'org_123' }), - ); + expect(mockSdk.events.listEvents).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 'org_123' })); }); describe('JSON mode', () => { @@ -163,7 +183,11 @@ describe('debug-sync command', () => { 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' }] }); + mockCountsAndEvents({ + users: 1, + groups: 1, + events: [{ id: 'e', event: 'dsync.user.created', createdAt: '2024-01-01' }], + }); await runDebugSync('dir_123', 'sk_test'); @@ -188,7 +212,11 @@ describe('debug-sync command', () => { 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' }] }); + mockCountsAndEvents({ + users: 1, + hasMore: true, + events: [{ id: 'e', event: 'dsync.user.created', createdAt: '2024-01-01' }], + }); await runDebugSync('dir_123', 'sk_test'); diff --git a/src/commands/onboard-user.spec.ts b/src/commands/onboard-user.spec.ts index c90902f..1b3edbb 100644 --- a/src/commands/onboard-user.spec.ts +++ b/src/commands/onboard-user.spec.ts @@ -48,9 +48,7 @@ describe('onboard-user command', () => { await runOnboardUser({ email: 'alice@acme.com', org: 'org_123', role: 'admin' }, 'sk_test'); - expect(mockSdk.userManagement.sendInvitation).toHaveBeenCalledWith( - expect.objectContaining({ roleSlug: 'admin' }), - ); + expect(mockSdk.userManagement.sendInvitation).toHaveBeenCalledWith(expect.objectContaining({ roleSlug: 'admin' })); }); it('does not poll when --wait is not set', async () => { diff --git a/src/commands/seed.spec.ts b/src/commands/seed.spec.ts index 91b8e38..221e579 100644 --- a/src/commands/seed.spec.ts +++ b/src/commands/seed.spec.ts @@ -95,18 +95,28 @@ describe('seed command', () => { // 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' })); + 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'] }); + 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' })); + expect(mockSdk.organizations.createOrganization).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Test Org' }), + ); // Then config expect(mockExtensions.redirectUris.add).toHaveBeenCalledWith('http://localhost:3000/callback'); diff --git a/src/commands/setup-org.spec.ts b/src/commands/setup-org.spec.ts index 7a68625..802c861 100644 --- a/src/commands/setup-org.spec.ts +++ b/src/commands/setup-org.spec.ts @@ -70,8 +70,14 @@ describe('setup-org command', () => { 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' }); + 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 () => { diff --git a/src/utils/register-subcommand.spec.ts b/src/utils/register-subcommand.spec.ts index 0cfae0e..c43cf67 100644 --- a/src/utils/register-subcommand.spec.ts +++ b/src/utils/register-subcommand.spec.ts @@ -56,12 +56,7 @@ describe('registerSubcommand', () => { async () => {}, ); - expect(commandSpy).toHaveBeenCalledWith( - 'list', - 'List resources', - expect.any(Function), - expect.any(Function), - ); + expect(commandSpy).toHaveBeenCalledWith('list', 'List resources', expect.any(Function), expect.any(Function)); }); it('preserves positional args and appends required options', () => { @@ -121,7 +116,13 @@ describe('registerSubcommand', () => { it('returns the parent yargs instance', () => { const parent = yargs([]); - const result = registerSubcommand(parent, 'test', 'Test', (y) => y, async () => {}); + const result = registerSubcommand( + parent, + 'test', + 'Test', + (y) => y, + async () => {}, + ); expect(result).toBe(parent); }); @@ -139,11 +140,6 @@ describe('registerSubcommand', () => { async () => {}, ); - expect(commandSpy).toHaveBeenCalledWith( - 'broken', - 'Broken command', - expect.any(Function), - expect.any(Function), - ); + 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 index 5fc3fba..b87341d 100644 --- a/src/utils/register-subcommand.ts +++ b/src/utils/register-subcommand.ts @@ -27,9 +27,7 @@ export function registerSubcommand( 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 demanded = Object.keys(opts.demandedOptions || {}).filter((k) => !['help', 'version'].includes(k)); const requiredSuffix = demanded .map((k) => { From 68dfdabe8a993fd1158fb5b7117b99c528d20797 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 3 Mar 2026 17:14:32 -0600 Subject: [PATCH 14/16] fix: webhook create field name and expand smoke test coverage Fix webhook endpoint creation sending `url` instead of `endpoint_url` in the request body. Expand smoke test from 27 to 77 test cases with full CRUD lifecycles for all command groups. Treat structured API errors (400/404/422) as signature-valid passes. --- scripts/smoke-test.ts | 864 ++++++++++++++++++++++++++++++++++ src/lib/workos-client.spec.ts | 2 +- src/lib/workos-client.ts | 2 +- 3 files changed, 866 insertions(+), 2 deletions(-) create mode 100644 scripts/smoke-test.ts diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts new file mode 100644 index 0000000..acf51ec --- /dev/null +++ b/scripts/smoke-test.ts @@ -0,0 +1,864 @@ +/** + * 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!); + }); + + // ===================================================================== + // 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/src/lib/workos-client.spec.ts b/src/lib/workos-client.spec.ts index 7399246..390ca7c 100644 --- a/src/lib/workos-client.spec.ts +++ b/src/lib/workos-client.spec.ts @@ -86,7 +86,7 @@ describe('workos-client', () => { expect.objectContaining({ method: 'POST', path: '/webhook_endpoints', - body: { url: 'https://example.com/hook', events: ['user.created'] }, + body: { endpoint_url: 'https://example.com/hook', events: ['user.created'] }, }), ); expect(result).toBe(mockEndpoint); diff --git a/src/lib/workos-client.ts b/src/lib/workos-client.ts index cda76f4..b500f80 100644 --- a/src/lib/workos-client.ts +++ b/src/lib/workos-client.ts @@ -82,7 +82,7 @@ export function createWorkOSClient(apiKey?: string, baseUrl?: string): WorkOSCLI path: '/webhook_endpoints', apiKey: key, baseUrl: base, - body: { url: endpointUrl, events }, + body: { endpoint_url: endpointUrl, events }, }); }, async delete(id: string) { From dae641b28fbdc30a0818b75480ed75b99ac4b74d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 3 Mar 2026 18:02:13 -0600 Subject: [PATCH 15/16] docs: document all CLI commands and workflow recipes in README --- README.md | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 274 insertions(+), 8 deletions(-) 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 From 6ad99102fcd08346246bce34a1c3d8b929bd18b0 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 3 Mar 2026 18:02:56 -0600 Subject: [PATCH 16/16] test: add audit-log create-schema to smoke test --- scripts/smoke-test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scripts/smoke-test.ts b/scripts/smoke-test.ts index acf51ec..a203cbf 100644 --- a/scripts/smoke-test.ts +++ b/scripts/smoke-test.ts @@ -517,6 +517,23 @@ async function run() { 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)