diff --git a/README.md b/README.md index 572d0394..1508726e 100644 --- a/README.md +++ b/README.md @@ -381,9 +381,13 @@ workos session revoke ```bash workos connection list [--org] [--type] [--limit] workos connection get +workos connection create --org [--name] [--external-id] [--type] [--data ] [--file ] +workos connection update [--name] [--external-id] [--type] [--data ] [--file ] workos connection delete [--force] ``` +`connections` is accepted as an alias for `connection`. For `create` and `update`, pass nested fields such as `saml_options`, `oidc_options`, and `attribute_maps` as a JSON body via `--data`, `--file `, or `--file -` (stdin); field flags override matching top-level JSON fields. Creating and updating connections requires the Connections API migration capabilities to be enabled for your team. + #### directory ```bash diff --git a/src/bin.ts b/src/bin.ts index 639ef316..cfa5c8f8 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1506,7 +1506,7 @@ async function runCli(): Promise { ); return yargs.demandCommand(1, 'Please specify a session subcommand').strict(); }) - .command('connection', 'Manage SSO connections (read/delete)', (yargs) => { + .command(['connection', 'connections'], 'Manage SSO connections', (yargs) => { yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); registerSubcommand( yargs, @@ -1553,6 +1553,69 @@ async function runCli(): Promise { await runConnectionGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); }, ); + registerSubcommand( + yargs, + 'create', + 'Create a connection', + (y) => + y.options({ + org: { type: 'string', describe: 'Organization ID' }, + name: { type: 'string', describe: 'Connection name' }, + 'external-id': { type: 'string', describe: 'Customer-owned identifier' }, + type: { type: 'string', describe: 'Connection type (e.g. GenericSAML, GenericOIDC)' }, + data: { type: 'string', describe: 'JSON request body' }, + file: { type: 'string', describe: 'Read JSON request body from a file, or - for stdin' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConnectionCreate } = await import('./commands/connection.js'); + await runConnectionCreate( + { + org: argv.org, + name: argv.name, + externalId: argv.externalId, + type: argv.type, + data: argv.data, + file: argv.file, + }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); + registerSubcommand( + yargs, + 'update ', + 'Update a connection', + (y) => + y.positional('id', { type: 'string', demandOption: true }).options({ + name: { type: 'string', describe: 'Connection name' }, + 'external-id': { type: 'string', describe: 'Customer-owned identifier' }, + type: { type: 'string', describe: 'Connection type (e.g. GenericSAML, GenericOIDC)' }, + data: { type: 'string', describe: 'JSON request body' }, + file: { type: 'string', describe: 'Read JSON request body from a file, or - for stdin' }, + }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + + const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); + const { runConnectionUpdate } = await import('./commands/connection.js'); + await runConnectionUpdate( + argv.id, + { + name: argv.name, + externalId: argv.externalId, + type: argv.type, + data: argv.data, + file: argv.file, + }, + resolveApiKey({ apiKey: argv.apiKey }), + resolveApiBaseUrl(), + ); + }, + ); registerSubcommand( yargs, 'delete ', diff --git a/src/commands/connection.spec.ts b/src/commands/connection.spec.ts index 0c2e6d13..010bb16c 100644 --- a/src/commands/connection.spec.ts +++ b/src/commands/connection.spec.ts @@ -9,8 +9,13 @@ const mockSdk = { }, }; +const mockConnections = { + create: vi.fn(), + update: vi.fn(), +}; + vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), + createWorkOSClient: () => ({ sdk: mockSdk, connections: mockConnections }), })); // Mock the UI facade @@ -27,7 +32,8 @@ vi.mock('../utils/ui.js', () => ({ const { setOutputMode } = await import('../utils/output.js'); const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); -const { runConnectionList, runConnectionGet, runConnectionDelete } = await import('./connection.js'); +const { runConnectionList, runConnectionGet, runConnectionDelete, runConnectionCreate, runConnectionUpdate } = + await import('./connection.js'); const { CliExit } = await import('../utils/cli-exit.js'); const mockConnection = { @@ -41,6 +47,18 @@ const mockConnection = { domains: [], }; +const mockApiConnection = { + object: 'connection', + id: 'conn_01ABC', + organization_id: 'org_123', + name: 'Okta SSO', + connection_type: 'GenericSAML', + state: 'active', + external_id: 'legacy-42', + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', +}; + describe('connection commands', () => { let consoleOutput: string[]; let stderrOutput: string[]; @@ -118,6 +136,72 @@ describe('connection commands', () => { }); }); + describe('runConnectionCreate', () => { + it('creates from flags', async () => { + mockConnections.create.mockResolvedValue(mockApiConnection); + await runConnectionCreate({ org: 'org_123', name: 'Okta SSO', externalId: 'legacy-42' }, 'sk_test'); + expect(mockConnections.create).toHaveBeenCalledWith({ + organization_id: 'org_123', + name: 'Okta SSO', + external_id: 'legacy-42', + }); + expect(consoleOutput.some((l) => l.includes('Created connection'))).toBe(true); + }); + + it('creates from --data JSON body', async () => { + mockConnections.create.mockResolvedValue(mockApiConnection); + await runConnectionCreate( + { data: '{"organization_id":"org_123","saml_options":{"idp_metadata_url":"https://idp.example.com/md"}}' }, + 'sk_test', + ); + expect(mockConnections.create).toHaveBeenCalledWith({ + organization_id: 'org_123', + saml_options: { idp_metadata_url: 'https://idp.example.com/md' }, + }); + }); + + it('flags override --data body fields', async () => { + mockConnections.create.mockResolvedValue(mockApiConnection); + await runConnectionCreate({ org: 'org_456', data: '{"organization_id":"org_123","name":"A"}' }, 'sk_test'); + expect(mockConnections.create).toHaveBeenCalledWith({ organization_id: 'org_456', name: 'A' }); + }); + + it('requires an organization ID', async () => { + await expect(runConnectionCreate({ name: 'Okta SSO' }, 'sk_test')).rejects.toThrow(CliExit); + expect(mockConnections.create).not.toHaveBeenCalled(); + }); + + it('rejects invalid JSON in --data', async () => { + await expect(runConnectionCreate({ org: 'org_123', data: 'not json' }, 'sk_test')).rejects.toThrow(CliExit); + expect(mockConnections.create).not.toHaveBeenCalled(); + }); + + it('rejects a non-object JSON body', async () => { + await expect(runConnectionCreate({ org: 'org_123', data: '[1,2]' }, 'sk_test')).rejects.toThrow(CliExit); + expect(mockConnections.create).not.toHaveBeenCalled(); + }); + }); + + describe('runConnectionUpdate', () => { + it('updates from flags', async () => { + mockConnections.update.mockResolvedValue(mockApiConnection); + await runConnectionUpdate('conn_01ABC', { name: 'Renamed' }, 'sk_test'); + expect(mockConnections.update).toHaveBeenCalledWith('conn_01ABC', { name: 'Renamed' }); + expect(consoleOutput.some((l) => l.includes('Updated connection'))).toBe(true); + }); + + it('updates from --data JSON body', async () => { + mockConnections.update.mockResolvedValue(mockApiConnection); + await runConnectionUpdate('conn_01ABC', { data: '{"external_id":null}' }, 'sk_test'); + expect(mockConnections.update).toHaveBeenCalledWith('conn_01ABC', { external_id: null }); + }); + + it('rejects an empty update body', async () => { + await expect(runConnectionUpdate('conn_01ABC', {}, 'sk_test')).rejects.toThrow(CliExit); + expect(mockConnections.update).not.toHaveBeenCalled(); + }); + }); + describe('runConnectionDelete', () => { it('deletes after confirmation', async () => { mockConfirm.mockResolvedValue(true); @@ -200,5 +284,23 @@ describe('connection commands', () => { expect(output.status).toBe('ok'); expect(output.data.id).toBe('conn_01ABC'); }); + + it('runConnectionCreate outputs the success envelope with the connection', async () => { + mockConnections.create.mockResolvedValue(mockApiConnection); + await runConnectionCreate({ org: 'org_123' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('conn_01ABC'); + expect(output.data.connection_type).toBe('GenericSAML'); + }); + + it('runConnectionUpdate outputs the success envelope with the connection', async () => { + mockConnections.update.mockResolvedValue(mockApiConnection); + await runConnectionUpdate('conn_01ABC', { name: 'Renamed' }, 'sk_test'); + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.data.id).toBe('conn_01ABC'); + expect(output.data.external_id).toBe('legacy-42'); + }); }); }); diff --git a/src/commands/connection.ts b/src/commands/connection.ts index b9e8cd39..33eab8c5 100644 --- a/src/commands/connection.ts +++ b/src/commands/connection.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises'; import chalk from 'chalk'; import type { ConnectionType } from '@workos-inc/node'; import { createWorkOSClient } from '../lib/workos-client.js'; @@ -81,6 +82,128 @@ export async function runConnectionList( } } +export interface ConnectionBodyOptions { + org?: string; + name?: string; + externalId?: string; + type?: string; + data?: string; + file?: string; +} + +function isJsonObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function resolveConnectionBody(options: ConnectionBodyOptions): Promise> { + let body: Record = {}; + + let raw: string | undefined; + if (options.data !== undefined) { + raw = options.data; + } else if (options.file) { + if (options.file === '-') { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + raw = Buffer.concat(chunks).toString('utf-8'); + if (raw.length === 0) { + exitWithError({ + code: 'empty_stdin_body', + message: + 'Reading request body from stdin (--file -) yielded no data. Pipe data into the command or pass --data instead.', + }); + } + } else { + try { + raw = await readFile(options.file, 'utf-8'); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + exitWithError({ + code: 'file_read_error', + message: `Could not read request body file "${options.file}": ${message}`, + }); + } + } + } + + if (raw !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + exitWithError({ + code: 'invalid_json_body', + message: 'Request body must be valid JSON.', + }); + } + if (!isJsonObject(parsed)) { + exitWithError({ + code: 'invalid_json_body', + message: 'Request body must be a JSON object.', + }); + } + body = { ...parsed }; + } + + if (options.org !== undefined) body.organization_id = options.org; + if (options.name !== undefined) body.name = options.name; + if (options.externalId !== undefined) body.external_id = options.externalId; + if (options.type !== undefined) body.connection_type = options.type; + + return body; +} + +export async function runConnectionCreate( + options: ConnectionBodyOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const body = await resolveConnectionBody(options); + + if (typeof body.organization_id !== 'string' || body.organization_id.length === 0) { + exitWithError({ + code: 'missing_organization_id', + message: 'An organization ID is required. Pass --org or include organization_id in the JSON body.', + }); + } + + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const connection = await client.connections.create(body); + outputSuccess('Created connection', connection); + } catch (error) { + handleApiError(error); + } +} + +export async function runConnectionUpdate( + id: string, + options: ConnectionBodyOptions, + apiKey: string, + baseUrl?: string, +): Promise { + const body = await resolveConnectionBody(options); + + if (Object.keys(body).length === 0) { + exitWithError({ + code: 'empty_update_body', + message: 'Nothing to update. Pass at least one field flag, or a JSON body via --data or --file.', + }); + } + + const client = createWorkOSClient(apiKey, baseUrl); + + try { + const connection = await client.connections.update(id, body); + outputSuccess('Updated connection', connection); + } catch (error) { + handleApiError(error); + } +} + export async function runConnectionGet(id: string, apiKey: string, baseUrl?: string): Promise { const client = createWorkOSClient(apiKey, baseUrl); diff --git a/src/lib/command-aliases.ts b/src/lib/command-aliases.ts index 7a8632ef..86a43c80 100644 --- a/src/lib/command-aliases.ts +++ b/src/lib/command-aliases.ts @@ -7,5 +7,6 @@ */ export const COMMAND_ALIASES: Record = { org: 'organization', + connections: 'connection', claim: 'env.claim', }; diff --git a/src/lib/workos-api.ts b/src/lib/workos-api.ts index eaeaad29..dd7ce036 100644 --- a/src/lib/workos-api.ts +++ b/src/lib/workos-api.ts @@ -6,7 +6,7 @@ const DEFAULT_BASE_URL = 'https://api.workos.com'; export interface WorkOSRequestOptions { - method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; path: string; apiKey: string; baseUrl?: string; @@ -55,7 +55,7 @@ export async function workosRequest(options: WorkOSRequestOptions): Promise { }); }); + describe('connections', () => { + it('create calls POST /connections with body', async () => { + const mockConnection = { object: 'connection', id: 'conn_123', organization_id: 'org_123' }; + mockRequest.mockResolvedValue(mockConnection); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + const result = await client.connections.create({ organization_id: 'org_123', connection_type: 'OktaSAML' }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + path: '/connections', + apiKey: 'sk_test_123', + baseUrl: 'https://api.workos.com', + body: { organization_id: 'org_123', connection_type: 'OktaSAML' }, + }), + ); + expect(result).toBe(mockConnection); + }); + + it('update calls PATCH /connections/:id with encoded id and body', async () => { + const mockConnection = { object: 'connection', id: 'conn_123', organization_id: 'org_123' }; + mockRequest.mockResolvedValue(mockConnection); + + const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); + const result = await client.connections.update('conn 123/x', { name: 'Updated' }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PATCH', + path: '/connections/conn%20123%2Fx', + apiKey: 'sk_test_123', + baseUrl: 'https://api.workos.com', + body: { name: 'Updated' }, + }), + ); + expect(result).toBe(mockConnection); + }); + }); + describe('homepageUrl', () => { it('set calls correct path with body', async () => { mockRequest.mockResolvedValue(null); diff --git a/src/lib/workos-client.ts b/src/lib/workos-client.ts index 6ba7b222..4d283b01 100644 --- a/src/lib/workos-client.ts +++ b/src/lib/workos-client.ts @@ -27,6 +27,19 @@ export interface AuditLogRetention { retention_period_in_days: number; } +export interface SsoConnection { + object: 'connection'; + id: string; + organization_id: string; + name: string; + connection_type: string; + state: string; + external_id?: string | null; + created_at: string; + updated_at: string; + [key: string]: unknown; +} + export interface WorkOSCLIClient { sdk: WorkOS; webhooks: { @@ -48,6 +61,10 @@ export interface WorkOSCLIClient { getSchema(action: string): Promise; getRetention(orgId: string): Promise; }; + connections: { + create(body: Record): Promise; + update(id: string, body: Record): Promise; + }; } /** @@ -182,5 +199,26 @@ export function createWorkOSClient(apiKey?: string, baseUrl?: string): WorkOSCLI }); }, }, + + connections: { + async create(body: Record) { + return workosRequest({ + method: 'POST', + path: '/connections', + apiKey: key, + baseUrl: base, + body, + }); + }, + async update(id: string, body: Record) { + return workosRequest({ + method: 'PATCH', + path: `/connections/${encodeURIComponent(id)}`, + apiKey: key, + baseUrl: base, + body, + }); + }, + }, }; } diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index b6f6d6e2..c9a2b05f 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -801,7 +801,7 @@ const commands: CommandSchema[] = [ }, { name: 'connection', - description: 'Manage SSO connections (read/delete)', + description: 'Manage SSO connections', options: [insecureStorageOpt, apiKeyOpt], commands: [ { @@ -818,6 +818,66 @@ const commands: CommandSchema[] = [ description: 'Get a connection', positionals: [{ name: 'id', type: 'string', description: 'Connection ID', required: true }], }, + { + name: 'create', + description: 'Create a connection', + options: [ + { name: 'org', type: 'string', description: 'Organization ID', required: false, hidden: false }, + { name: 'name', type: 'string', description: 'Connection name', required: false, hidden: false }, + { + name: 'external-id', + type: 'string', + description: 'Customer-owned identifier', + required: false, + hidden: false, + }, + { + name: 'type', + type: 'string', + description: 'Connection type (e.g. GenericSAML, GenericOIDC)', + required: false, + hidden: false, + }, + { name: 'data', type: 'string', description: 'JSON request body', required: false, hidden: false }, + { + name: 'file', + type: 'string', + description: 'Read JSON request body from a file, or - for stdin', + required: false, + hidden: false, + }, + ], + }, + { + name: 'update', + description: 'Update a connection', + positionals: [{ name: 'id', type: 'string', description: 'Connection ID', required: true }], + options: [ + { name: 'name', type: 'string', description: 'Connection name', required: false, hidden: false }, + { + name: 'external-id', + type: 'string', + description: 'Customer-owned identifier', + required: false, + hidden: false, + }, + { + name: 'type', + type: 'string', + description: 'Connection type (e.g. GenericSAML, GenericOIDC)', + required: false, + hidden: false, + }, + { name: 'data', type: 'string', description: 'JSON request body', required: false, hidden: false }, + { + name: 'file', + type: 'string', + description: 'Read JSON request body from a file, or - for stdin', + required: false, + hidden: false, + }, + ], + }, { name: 'delete', description: 'Delete a connection',