Skip to content

Commit 19b34e2

Browse files
authored
fix(auth): auto-provision staging environment after login (#89)
* feat(auth): auto-provision staging environment after login After workos auth login succeeds, automatically fetch staging credentials and save them as a staging environment in the config store. This eliminates the need for users to manually run workos env add before management commands work. If the staging API fails (403/404/network), the login still succeeds and a hint is printed to configure an environment manually. * chore: remove .case-tested
1 parent c86c662 commit 19b34e2

2 files changed

Lines changed: 238 additions & 1 deletion

File tree

src/commands/login.spec.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } 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+
logInfo: vi.fn(),
9+
logError: vi.fn(),
10+
logWarn: vi.fn(),
11+
}));
12+
13+
// Mock clack prompts
14+
vi.mock('../utils/clack.js', () => ({
15+
default: {
16+
log: {
17+
success: vi.fn(),
18+
error: vi.fn(),
19+
info: vi.fn(),
20+
step: vi.fn(),
21+
},
22+
spinner: vi.fn(() => ({
23+
start: vi.fn(),
24+
stop: vi.fn(),
25+
})),
26+
isCancel: vi.fn(() => false),
27+
},
28+
}));
29+
30+
// Mock staging API — we control it per test
31+
const mockFetchStagingCredentials = vi.fn();
32+
vi.mock('../lib/staging-api.js', () => ({
33+
fetchStagingCredentials: (...args: unknown[]) => mockFetchStagingCredentials(...args),
34+
}));
35+
36+
let testDir: string;
37+
38+
vi.mock('node:os', async (importOriginal) => {
39+
const original = await importOriginal<typeof import('node:os')>();
40+
return {
41+
...original,
42+
default: {
43+
...original,
44+
homedir: () => testDir,
45+
},
46+
homedir: () => testDir,
47+
};
48+
});
49+
50+
const { getConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js');
51+
const { provisionStagingEnvironment } = await import('./login.js');
52+
53+
describe('login', () => {
54+
beforeEach(() => {
55+
testDir = mkdtempSync(join(tmpdir(), 'login-test-'));
56+
setInsecureConfigStorage(true);
57+
vi.clearAllMocks();
58+
});
59+
60+
afterEach(() => {
61+
clearConfig();
62+
try {
63+
rmdirSync(join(testDir, '.workos'), { recursive: true });
64+
} catch {}
65+
try {
66+
rmdirSync(testDir);
67+
} catch {}
68+
});
69+
70+
describe('provisionStagingEnvironment', () => {
71+
it('creates a staging environment on success', async () => {
72+
mockFetchStagingCredentials.mockResolvedValueOnce({
73+
clientId: 'client_staging_123',
74+
apiKey: 'sk_test_staging_abc',
75+
});
76+
77+
const result = await provisionStagingEnvironment('access_token_xyz');
78+
79+
expect(result).toBe(true);
80+
expect(mockFetchStagingCredentials).toHaveBeenCalledWith('access_token_xyz');
81+
82+
const config = getConfig();
83+
expect(config).not.toBeNull();
84+
expect(config?.environments['staging']).toEqual({
85+
name: 'staging',
86+
type: 'sandbox',
87+
apiKey: 'sk_test_staging_abc',
88+
clientId: 'client_staging_123',
89+
});
90+
});
91+
92+
it('sets staging as active environment when no environments exist', async () => {
93+
mockFetchStagingCredentials.mockResolvedValueOnce({
94+
clientId: 'client_123',
95+
apiKey: 'sk_test_abc',
96+
});
97+
98+
await provisionStagingEnvironment('token');
99+
100+
const config = getConfig();
101+
expect(config?.activeEnvironment).toBe('staging');
102+
});
103+
104+
it('does not change active environment when one already exists', async () => {
105+
// Pre-create an environment
106+
const { saveConfig } = await import('../lib/config-store.js');
107+
saveConfig({
108+
activeEnvironment: 'production',
109+
environments: {
110+
production: {
111+
name: 'production',
112+
type: 'production',
113+
apiKey: 'sk_live_existing',
114+
},
115+
},
116+
});
117+
118+
mockFetchStagingCredentials.mockResolvedValueOnce({
119+
clientId: 'client_123',
120+
apiKey: 'sk_test_abc',
121+
});
122+
123+
await provisionStagingEnvironment('token');
124+
125+
const config = getConfig();
126+
expect(config?.activeEnvironment).toBe('production');
127+
expect(config?.environments['staging']).toBeDefined();
128+
expect(config?.environments['production']).toBeDefined();
129+
});
130+
131+
it('updates existing staging environment if already present', async () => {
132+
const { saveConfig } = await import('../lib/config-store.js');
133+
saveConfig({
134+
activeEnvironment: 'staging',
135+
environments: {
136+
staging: {
137+
name: 'staging',
138+
type: 'sandbox',
139+
apiKey: 'sk_test_old',
140+
clientId: 'client_old',
141+
},
142+
},
143+
});
144+
145+
mockFetchStagingCredentials.mockResolvedValueOnce({
146+
clientId: 'client_new',
147+
apiKey: 'sk_test_new',
148+
});
149+
150+
const result = await provisionStagingEnvironment('token');
151+
152+
expect(result).toBe(true);
153+
const config = getConfig();
154+
expect(config?.environments['staging']?.apiKey).toBe('sk_test_new');
155+
expect(config?.environments['staging']?.clientId).toBe('client_new');
156+
});
157+
158+
it('returns false and does not throw on API 403 error', async () => {
159+
mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Access denied'));
160+
161+
const result = await provisionStagingEnvironment('token');
162+
163+
expect(result).toBe(false);
164+
const config = getConfig();
165+
expect(config).toBeNull();
166+
});
167+
168+
it('returns false and does not throw on API 404 error', async () => {
169+
mockFetchStagingCredentials.mockRejectedValueOnce(new Error('No staging environment found'));
170+
171+
const result = await provisionStagingEnvironment('token');
172+
173+
expect(result).toBe(false);
174+
});
175+
176+
it('returns false and does not throw on network error', async () => {
177+
mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Network error'));
178+
179+
const result = await provisionStagingEnvironment('token');
180+
181+
expect(result).toBe(false);
182+
});
183+
184+
it('returns false and does not throw on timeout', async () => {
185+
mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Request timed out'));
186+
187+
const result = await provisionStagingEnvironment('token');
188+
189+
expect(result).toBe(false);
190+
});
191+
});
192+
});

src/commands/login.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import clack from '../utils/clack.js';
44
import { saveCredentials, getCredentials, getAccessToken, isTokenExpired, updateTokens } from '../lib/credentials.js';
55
import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js';
66
import { refreshAccessToken } from '../lib/token-refresh-client.js';
7-
import { logInfo } from '../utils/debug.js';
7+
import { logInfo, logError } from '../utils/debug.js';
8+
import { fetchStagingCredentials } from '../lib/staging-api.js';
9+
import { getConfig, saveConfig } from '../lib/config-store.js';
10+
import type { CliConfig } from '../lib/config-store.js';
811

912
/**
1013
* Parse JWT payload
@@ -66,6 +69,40 @@ function sleep(ms: number): Promise<void> {
6669
return new Promise((resolve) => setTimeout(resolve, ms));
6770
}
6871

72+
/**
73+
* Auto-provision a staging environment after login.
74+
*
75+
* Fetches staging credentials using the access token, then saves them
76+
* as a "staging" environment in the config store. Non-fatal — logs a
77+
* hint on failure instead of throwing.
78+
*/
79+
export async function provisionStagingEnvironment(accessToken: string): Promise<boolean> {
80+
try {
81+
const staging = await fetchStagingCredentials(accessToken);
82+
83+
const config: CliConfig = getConfig() ?? { environments: {} };
84+
const isFirst = Object.keys(config.environments).length === 0;
85+
86+
config.environments['staging'] = {
87+
name: 'staging',
88+
type: 'sandbox',
89+
apiKey: staging.apiKey,
90+
clientId: staging.clientId,
91+
};
92+
93+
if (isFirst || !config.activeEnvironment) {
94+
config.activeEnvironment = 'staging';
95+
}
96+
97+
saveConfig(config);
98+
logInfo('[login] Staging environment auto-provisioned');
99+
return true;
100+
} catch (error) {
101+
logError('[login] Failed to auto-provision staging environment:', error instanceof Error ? error.message : error);
102+
return false;
103+
}
104+
}
105+
69106
export async function runLogin(): Promise<void> {
70107
const clientId = getCliAuthClientId();
71108

@@ -184,6 +221,14 @@ export async function runLogin(): Promise<void> {
184221
spinner.stop('Authentication successful!');
185222
clack.log.success(`Logged in as ${email || userId}`);
186223
clack.log.info(`Token expires in ${expiresInSec} seconds`);
224+
225+
// Auto-provision staging environment
226+
const provisioned = await provisionStagingEnvironment(result.access_token);
227+
if (provisioned) {
228+
clack.log.success('Staging environment configured automatically');
229+
} else {
230+
clack.log.info(chalk.dim('Run `workos env add` to configure an environment manually'));
231+
}
187232
return;
188233
}
189234

0 commit comments

Comments
 (0)