Skip to content

Commit 4dd0175

Browse files
committed
feat: add environment management commands and config store
Add `workos env` command group (add/remove/switch/list) for managing multiple environments with API keys. Introduces a keyring-backed config store (with file fallback) separate from OAuth credentials, and an API key resolution utility for upcoming management commands.
1 parent 0ef8127 commit 4dd0175

7 files changed

Lines changed: 945 additions & 1 deletion

File tree

src/bin.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,13 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) {
3131
import { isNonInteractiveEnvironment } from './utils/environment.js';
3232
import clack from './utils/clack.js';
3333

34-
/** Apply insecure storage flag if set */
34+
/** Apply insecure storage flag if set (for both credential-store and config-store) */
3535
async function applyInsecureStorage(insecureStorage?: boolean): Promise<void> {
3636
if (insecureStorage) {
3737
const { setInsecureStorage } = await import('./lib/credentials.js');
38+
const { setInsecureConfigStorage } = await import('./lib/config-store.js');
3839
setInsecureStorage(true);
40+
setInsecureConfigStorage(true);
3941
}
4042
}
4143

@@ -223,6 +225,60 @@ yargs(hideBin(process.argv))
223225
await handleDoctor(argv);
224226
},
225227
)
228+
.command('env', 'Manage environment configurations', (yargs) =>
229+
yargs
230+
.options(insecureStorageOption)
231+
.command(
232+
'add [name] [apiKey]',
233+
'Add an environment configuration',
234+
(yargs) =>
235+
yargs
236+
.positional('name', { type: 'string', describe: 'Environment name' })
237+
.positional('apiKey', { type: 'string', describe: 'WorkOS API key' })
238+
.option('endpoint', { type: 'string', describe: 'Custom API endpoint' }),
239+
async (argv) => {
240+
await applyInsecureStorage(argv.insecureStorage);
241+
const { runEnvAdd } = await import('./commands/env.js');
242+
await runEnvAdd({
243+
name: argv.name,
244+
apiKey: argv.apiKey,
245+
endpoint: argv.endpoint,
246+
});
247+
},
248+
)
249+
.command(
250+
'remove <name>',
251+
'Remove an environment configuration',
252+
(yargs) => yargs.positional('name', { type: 'string', demandOption: true, describe: 'Environment name' }),
253+
async (argv) => {
254+
await applyInsecureStorage(argv.insecureStorage);
255+
const { runEnvRemove } = await import('./commands/env.js');
256+
await runEnvRemove(argv.name);
257+
},
258+
)
259+
.command(
260+
'switch [name]',
261+
'Switch active environment',
262+
(yargs) => yargs.positional('name', { type: 'string', describe: 'Environment name' }),
263+
async (argv) => {
264+
await applyInsecureStorage(argv.insecureStorage);
265+
const { runEnvSwitch } = await import('./commands/env.js');
266+
await runEnvSwitch(argv.name);
267+
},
268+
)
269+
.command(
270+
'list',
271+
'List configured environments',
272+
{},
273+
async (argv) => {
274+
await applyInsecureStorage((argv as any).insecureStorage);
275+
const { runEnvList } = await import('./commands/env.js');
276+
await runEnvList();
277+
},
278+
)
279+
.demandCommand(1, 'Please specify an env subcommand')
280+
.strict(),
281+
)
226282
.command(
227283
'install',
228284
'Install WorkOS AuthKit into your project',

src/commands/env.spec.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import { mkdtempSync, rmdirSync } from 'node:fs';
3+
import { join } from 'node:path';
4+
import { tmpdir } from 'node:os';
5+
6+
// Mock debug utilities
7+
vi.mock('../utils/debug.js', () => ({
8+
logWarn: vi.fn(),
9+
}));
10+
11+
// Mock clack prompts
12+
vi.mock('../utils/clack.js', () => ({
13+
default: {
14+
log: {
15+
success: vi.fn(),
16+
error: vi.fn(),
17+
info: vi.fn(),
18+
step: vi.fn(),
19+
},
20+
text: vi.fn(),
21+
select: vi.fn(),
22+
password: vi.fn(),
23+
isCancel: vi.fn(() => false),
24+
},
25+
}));
26+
27+
let testDir: string;
28+
29+
vi.mock('node:os', async (importOriginal) => {
30+
const original = await importOriginal<typeof import('node:os')>();
31+
return {
32+
...original,
33+
default: {
34+
...original,
35+
homedir: () => testDir,
36+
},
37+
homedir: () => testDir,
38+
};
39+
});
40+
41+
const { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js');
42+
const { runEnvAdd, runEnvRemove, runEnvSwitch, runEnvList } = await import('./env.js');
43+
const clack = (await import('../utils/clack.js')).default;
44+
45+
// Spy on process.exit
46+
const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
47+
throw new Error('process.exit called');
48+
}) as any);
49+
50+
describe('env commands', () => {
51+
beforeEach(() => {
52+
testDir = mkdtempSync(join(tmpdir(), 'env-cmd-test-'));
53+
setInsecureConfigStorage(true);
54+
vi.clearAllMocks();
55+
});
56+
57+
afterEach(() => {
58+
clearConfig();
59+
try {
60+
rmdirSync(join(testDir, '.workos'), { recursive: true });
61+
} catch {}
62+
try {
63+
rmdirSync(testDir);
64+
} catch {}
65+
});
66+
67+
describe('runEnvAdd (non-interactive)', () => {
68+
it('adds an environment with provided args', async () => {
69+
await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc123' });
70+
const config = getConfig();
71+
expect(config?.environments.prod).toBeDefined();
72+
expect(config?.environments.prod.apiKey).toBe('sk_live_abc123');
73+
expect(config?.environments.prod.type).toBe('production');
74+
});
75+
76+
it('detects sandbox type from sk_test_ prefix', async () => {
77+
await runEnvAdd({ name: 'sandbox', apiKey: 'sk_test_abc123' });
78+
const config = getConfig();
79+
expect(config?.environments.sandbox.type).toBe('sandbox');
80+
});
81+
82+
it('stores endpoint when provided', async () => {
83+
await runEnvAdd({ name: 'local', apiKey: 'sk_test_abc', endpoint: 'http://localhost:8001' });
84+
const config = getConfig();
85+
expect(config?.environments.local.endpoint).toBe('http://localhost:8001');
86+
});
87+
88+
it('auto-sets active environment on first add', async () => {
89+
await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' });
90+
const config = getConfig();
91+
expect(config?.activeEnvironment).toBe('prod');
92+
});
93+
94+
it('does not change active environment on subsequent adds', async () => {
95+
await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' });
96+
await runEnvAdd({ name: 'sandbox', apiKey: 'sk_test_abc' });
97+
const config = getConfig();
98+
expect(config?.activeEnvironment).toBe('prod');
99+
});
100+
101+
it('rejects invalid environment name', async () => {
102+
await expect(runEnvAdd({ name: 'INVALID NAME', apiKey: 'sk_test' })).rejects.toThrow('process.exit');
103+
});
104+
});
105+
106+
describe('runEnvRemove', () => {
107+
it('removes an existing environment', async () => {
108+
await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' });
109+
await runEnvRemove('prod');
110+
const config = getConfig();
111+
expect(config?.environments.prod).toBeUndefined();
112+
});
113+
114+
it('switches active env when removing the active one', async () => {
115+
await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' });
116+
await runEnvAdd({ name: 'sandbox', apiKey: 'sk_test_abc' });
117+
// prod is active (first added)
118+
await runEnvRemove('prod');
119+
const config = getConfig();
120+
expect(config?.activeEnvironment).toBe('sandbox');
121+
});
122+
123+
it('errors for non-existent environment', async () => {
124+
await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' });
125+
await expect(runEnvRemove('missing')).rejects.toThrow('process.exit');
126+
});
127+
128+
it('errors when no environments configured', async () => {
129+
await expect(runEnvRemove('anything')).rejects.toThrow('process.exit');
130+
});
131+
});
132+
133+
describe('runEnvSwitch', () => {
134+
it('switches to a named environment', async () => {
135+
await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' });
136+
await runEnvAdd({ name: 'sandbox', apiKey: 'sk_test_abc' });
137+
await runEnvSwitch('sandbox');
138+
const config = getConfig();
139+
expect(config?.activeEnvironment).toBe('sandbox');
140+
});
141+
142+
it('errors for non-existent environment', async () => {
143+
await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' });
144+
await expect(runEnvSwitch('missing')).rejects.toThrow('process.exit');
145+
});
146+
147+
it('errors when no environments configured', async () => {
148+
await expect(runEnvSwitch('anything')).rejects.toThrow('process.exit');
149+
});
150+
});
151+
152+
describe('runEnvList', () => {
153+
it('shows info message when no environments', async () => {
154+
await runEnvList();
155+
expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('No environments configured'));
156+
});
157+
158+
it('does not throw when environments exist', async () => {
159+
await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' });
160+
await expect(runEnvList()).resolves.not.toThrow();
161+
});
162+
});
163+
});

0 commit comments

Comments
 (0)