Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,9 +381,13 @@ workos session revoke <sessionId>
```bash
workos connection list [--org] [--type] [--limit]
workos connection get <id>
workos connection create --org <orgId> [--name] [--external-id] [--type] [--data <json>] [--file <path|->]
workos connection update <id> [--name] [--external-id] [--type] [--data <json>] [--file <path|->]
workos connection delete <id> [--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 <path>`, 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
Expand Down
65 changes: 64 additions & 1 deletion src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1506,7 +1506,7 @@ async function runCli(): Promise<void> {
);
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,
Expand Down Expand Up @@ -1553,6 +1553,69 @@ async function runCli(): Promise<void> {
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 <id>',
'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 <id>',
Expand Down
106 changes: 104 additions & 2 deletions src/commands/connection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand All @@ -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[];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
});
});
});
123 changes: 123 additions & 0 deletions src/commands/connection.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

async function resolveConnectionBody(options: ConnectionBodyOptions): Promise<Record<string, unknown>> {
let body: Record<string, unknown> = {};

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<void> {
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<void> {
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<void> {
const client = createWorkOSClient(apiKey, baseUrl);

Expand Down
1 change: 1 addition & 0 deletions src/lib/command-aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
*/
export const COMMAND_ALIASES: Record<string, string> = {
org: 'organization',
connections: 'connection',
claim: 'env.claim',
};
4 changes: 2 additions & 2 deletions src/lib/workos-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -55,7 +55,7 @@ export async function workosRequest<T>(options: WorkOSRequestOptions): Promise<T

const fetchOptions: RequestInit = { method, headers };

if (body && (method === 'POST' || method === 'PUT')) {
if (body && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
headers['Content-Type'] = 'application/json';
fetchOptions.body = JSON.stringify(body);
}
Expand Down
Loading
Loading