Skip to content

Commit 3485fe5

Browse files
committed
feat: add organization management commands
Add `workos organization` command group (create/update/get/list/delete) for managing WorkOS organizations via the REST API. Includes a generic WorkOS API client for reuse with future resource types and a table formatter for list output.
1 parent 4dd0175 commit 3485fe5

6 files changed

Lines changed: 750 additions & 0 deletions

File tree

src/bin.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,96 @@ yargs(hideBin(process.argv))
279279
.demandCommand(1, 'Please specify an env subcommand')
280280
.strict(),
281281
)
282+
.command('organization', 'Manage organizations', (yargs) =>
283+
yargs
284+
.options({
285+
...insecureStorageOption,
286+
'api-key': { type: 'string' as const, describe: 'WorkOS API key (overrides environment config)' },
287+
})
288+
.command(
289+
'create <name> [domains..]',
290+
'Create a new organization',
291+
(yargs) =>
292+
yargs
293+
.positional('name', { type: 'string', demandOption: true, describe: 'Organization name' })
294+
.positional('domains', { type: 'string', array: true, describe: 'Domains as domain:state' }),
295+
async (argv) => {
296+
await applyInsecureStorage(argv.insecureStorage);
297+
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
298+
const { runOrgCreate } = await import('./commands/organization.js');
299+
const apiKey = resolveApiKey({ apiKey: argv.apiKey });
300+
await runOrgCreate(argv.name, (argv.domains as string[]) || [], apiKey, resolveApiBaseUrl());
301+
},
302+
)
303+
.command(
304+
'update <orgId> <name> [domain] [state]',
305+
'Update an organization',
306+
(yargs) =>
307+
yargs
308+
.positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' })
309+
.positional('name', { type: 'string', demandOption: true, describe: 'Organization name' })
310+
.positional('domain', { type: 'string', describe: 'Domain' })
311+
.positional('state', { type: 'string', describe: 'Domain state (verified or pending)' }),
312+
async (argv) => {
313+
await applyInsecureStorage(argv.insecureStorage);
314+
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
315+
const { runOrgUpdate } = await import('./commands/organization.js');
316+
const apiKey = resolveApiKey({ apiKey: argv.apiKey });
317+
await runOrgUpdate(argv.orgId, argv.name, argv.domain, argv.state, apiKey, resolveApiBaseUrl());
318+
},
319+
)
320+
.command(
321+
'get <orgId>',
322+
'Get an organization by ID',
323+
(yargs) =>
324+
yargs.positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }),
325+
async (argv) => {
326+
await applyInsecureStorage(argv.insecureStorage);
327+
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
328+
const { runOrgGet } = await import('./commands/organization.js');
329+
const apiKey = resolveApiKey({ apiKey: argv.apiKey });
330+
await runOrgGet(argv.orgId, apiKey, resolveApiBaseUrl());
331+
},
332+
)
333+
.command(
334+
'list',
335+
'List organizations',
336+
(yargs) =>
337+
yargs.options({
338+
domain: { type: 'string', describe: 'Filter by domain' },
339+
limit: { type: 'number', describe: 'Limit number of results' },
340+
before: { type: 'string', describe: 'Cursor for results before a specific item' },
341+
after: { type: 'string', describe: 'Cursor for results after a specific item' },
342+
order: { type: 'string', describe: 'Order of results (asc or desc)' },
343+
}),
344+
async (argv) => {
345+
await applyInsecureStorage(argv.insecureStorage);
346+
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
347+
const { runOrgList } = await import('./commands/organization.js');
348+
const apiKey = resolveApiKey({ apiKey: argv.apiKey });
349+
await runOrgList(
350+
{ domain: argv.domain, limit: argv.limit, before: argv.before, after: argv.after, order: argv.order },
351+
apiKey,
352+
resolveApiBaseUrl(),
353+
);
354+
},
355+
)
356+
.command(
357+
'delete <orgId>',
358+
'Delete an organization',
359+
(yargs) =>
360+
yargs.positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }),
361+
async (argv) => {
362+
await applyInsecureStorage(argv.insecureStorage);
363+
const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js');
364+
const { runOrgDelete } = await import('./commands/organization.js');
365+
const apiKey = resolveApiKey({ apiKey: argv.apiKey });
366+
await runOrgDelete(argv.orgId, apiKey, resolveApiBaseUrl());
367+
},
368+
)
369+
.demandCommand(1, 'Please specify an organization subcommand')
370+
.strict(),
371+
)
282372
.command(
283373
'install',
284374
'Install WorkOS AuthKit into your project',

src/commands/organization.spec.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
3+
// Mock workos-api
4+
vi.mock('../lib/workos-api.js', () => ({
5+
workosRequest: vi.fn(),
6+
WorkOSApiError: class WorkOSApiError extends Error {
7+
constructor(
8+
message: string,
9+
public readonly statusCode: number,
10+
public readonly code?: string,
11+
public readonly errors?: Array<{ message: string }>,
12+
) {
13+
super(message);
14+
this.name = 'WorkOSApiError';
15+
}
16+
},
17+
}));
18+
19+
const { workosRequest } = await import('../lib/workos-api.js');
20+
const mockRequest = vi.mocked(workosRequest);
21+
22+
const { runOrgCreate, runOrgUpdate, runOrgGet, runOrgList, runOrgDelete, parseDomainArgs } = await import(
23+
'./organization.js'
24+
);
25+
26+
describe('organization commands', () => {
27+
let consoleOutput: string[];
28+
29+
beforeEach(() => {
30+
mockRequest.mockReset();
31+
consoleOutput = [];
32+
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
33+
consoleOutput.push(args.map(String).join(' '));
34+
});
35+
});
36+
37+
afterEach(() => {
38+
vi.restoreAllMocks();
39+
});
40+
41+
describe('parseDomainArgs', () => {
42+
it('parses domain:state format', () => {
43+
expect(parseDomainArgs(['foo.com:verified'])).toEqual([{ domain: 'foo.com', state: 'verified' }]);
44+
});
45+
46+
it('defaults state to verified', () => {
47+
expect(parseDomainArgs(['foo.com'])).toEqual([{ domain: 'foo.com', state: 'verified' }]);
48+
});
49+
50+
it('parses multiple domains', () => {
51+
const result = parseDomainArgs(['foo.com:verified', 'bar.com:pending']);
52+
expect(result).toHaveLength(2);
53+
expect(result[1]).toEqual({ domain: 'bar.com', state: 'pending' });
54+
});
55+
56+
it('returns empty array for no args', () => {
57+
expect(parseDomainArgs([])).toEqual([]);
58+
});
59+
});
60+
61+
describe('runOrgCreate', () => {
62+
it('creates org with name only', async () => {
63+
mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] });
64+
await runOrgCreate('Test', [], 'sk_test');
65+
expect(mockRequest).toHaveBeenCalledWith(
66+
expect.objectContaining({
67+
method: 'POST',
68+
path: '/organizations',
69+
body: { name: 'Test' },
70+
}),
71+
);
72+
});
73+
74+
it('creates org with domain data', async () => {
75+
mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] });
76+
await runOrgCreate('Test', ['foo.com:pending'], 'sk_test');
77+
expect(mockRequest).toHaveBeenCalledWith(
78+
expect.objectContaining({
79+
body: {
80+
name: 'Test',
81+
domain_data: [{ domain: 'foo.com', state: 'pending' }],
82+
},
83+
}),
84+
);
85+
});
86+
87+
it('outputs created message and JSON', async () => {
88+
mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] });
89+
await runOrgCreate('Test', [], 'sk_test');
90+
expect(consoleOutput.some((l) => l.includes('Created organization'))).toBe(true);
91+
});
92+
});
93+
94+
describe('runOrgUpdate', () => {
95+
it('updates org name', async () => {
96+
mockRequest.mockResolvedValue({ id: 'org_123', name: 'Updated' });
97+
await runOrgUpdate('org_123', 'Updated', undefined, undefined, 'sk_test');
98+
expect(mockRequest).toHaveBeenCalledWith(
99+
expect.objectContaining({
100+
method: 'PUT',
101+
path: '/organizations/org_123',
102+
body: { name: 'Updated' },
103+
}),
104+
);
105+
});
106+
107+
it('updates org with domain data', async () => {
108+
mockRequest.mockResolvedValue({ id: 'org_123', name: 'Updated' });
109+
await runOrgUpdate('org_123', 'Updated', 'foo.com', 'pending', 'sk_test');
110+
expect(mockRequest).toHaveBeenCalledWith(
111+
expect.objectContaining({
112+
body: {
113+
name: 'Updated',
114+
domain_data: [{ domain: 'foo.com', state: 'pending' }],
115+
},
116+
}),
117+
);
118+
});
119+
});
120+
121+
describe('runOrgGet', () => {
122+
it('fetches and prints org as JSON', async () => {
123+
mockRequest.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] });
124+
await runOrgGet('org_123', 'sk_test');
125+
expect(mockRequest).toHaveBeenCalledWith(
126+
expect.objectContaining({ method: 'GET', path: '/organizations/org_123' }),
127+
);
128+
// Should print JSON
129+
expect(consoleOutput.some((l) => l.includes('org_123'))).toBe(true);
130+
});
131+
});
132+
133+
describe('runOrgList', () => {
134+
it('lists orgs in table format', async () => {
135+
mockRequest.mockResolvedValue({
136+
data: [
137+
{
138+
id: 'org_123',
139+
name: 'FooCorp',
140+
domains: [{ id: 'd_1', domain: 'foo.com', state: 'verified' }],
141+
},
142+
],
143+
list_metadata: { before: null, after: null },
144+
});
145+
await runOrgList({}, 'sk_test');
146+
expect(mockRequest).toHaveBeenCalledWith(
147+
expect.objectContaining({ method: 'GET', path: '/organizations' }),
148+
);
149+
// Should contain table data
150+
expect(consoleOutput.some((l) => l.includes('FooCorp'))).toBe(true);
151+
});
152+
153+
it('passes filter params', async () => {
154+
mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } });
155+
await runOrgList({ domain: 'foo.com', limit: 5, order: 'desc' }, 'sk_test');
156+
expect(mockRequest).toHaveBeenCalledWith(
157+
expect.objectContaining({
158+
params: expect.objectContaining({ domains: 'foo.com', limit: 5, order: 'desc' }),
159+
}),
160+
);
161+
});
162+
163+
it('handles empty results', async () => {
164+
mockRequest.mockResolvedValue({ data: [], list_metadata: { before: null, after: null } });
165+
await runOrgList({}, 'sk_test');
166+
expect(consoleOutput.some((l) => l.includes('No organizations found'))).toBe(true);
167+
});
168+
169+
it('shows pagination cursors', async () => {
170+
mockRequest.mockResolvedValue({
171+
data: [{ id: 'org_1', name: 'Test', domains: [] }],
172+
list_metadata: { before: 'cursor_b', after: 'cursor_a' },
173+
});
174+
await runOrgList({}, 'sk_test');
175+
expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true);
176+
expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true);
177+
});
178+
});
179+
180+
describe('runOrgDelete', () => {
181+
it('deletes org and prints confirmation', async () => {
182+
mockRequest.mockResolvedValue(null);
183+
await runOrgDelete('org_123', 'sk_test');
184+
expect(mockRequest).toHaveBeenCalledWith(
185+
expect.objectContaining({ method: 'DELETE', path: '/organizations/org_123' }),
186+
);
187+
expect(consoleOutput.some((l) => l.includes('Deleted') && l.includes('org_123'))).toBe(true);
188+
});
189+
});
190+
});

0 commit comments

Comments
 (0)