From e6c8df9d764c640f43015036ac593a76cc115f21 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 9 Mar 2026 15:35:27 -0500 Subject: [PATCH 1/2] 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. --- .case-tested | 4 + src/commands/login.spec.ts | 192 +++++++++++++++++++++++++++++++++++++ src/commands/login.ts | 47 ++++++++- 3 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 .case-tested create mode 100644 src/commands/login.spec.ts diff --git a/.case-tested b/.case-tested new file mode 100644 index 0000000..ab151a7 --- /dev/null +++ b/.case-tested @@ -0,0 +1,4 @@ +timestamp: 2026-03-09T20:22:27Z +output_hash: 69a44538eaa013a63ad9b46ab32a6f74770ad96a0e1866e2b13ba06fbebafa92 +pass_indicators: 76 +fail_indicators: 18 diff --git a/src/commands/login.spec.ts b/src/commands/login.spec.ts new file mode 100644 index 0000000..bb15df3 --- /dev/null +++ b/src/commands/login.spec.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// Mock debug utilities +vi.mock('../utils/debug.js', () => ({ + logInfo: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), +})); + +// Mock clack prompts +vi.mock('../utils/clack.js', () => ({ + default: { + log: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + step: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn(), + stop: vi.fn(), + })), + isCancel: vi.fn(() => false), + }, +})); + +// Mock staging API — we control it per test +const mockFetchStagingCredentials = vi.fn(); +vi.mock('../lib/staging-api.js', () => ({ + fetchStagingCredentials: (...args: unknown[]) => mockFetchStagingCredentials(...args), +})); + +let testDir: string; + +vi.mock('node:os', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + default: { + ...original, + homedir: () => testDir, + }, + homedir: () => testDir, + }; +}); + +const { getConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); +const { provisionStagingEnvironment } = await import('./login.js'); + +describe('login', () => { + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'login-test-')); + setInsecureConfigStorage(true); + vi.clearAllMocks(); + }); + + afterEach(() => { + clearConfig(); + try { + rmdirSync(join(testDir, '.workos'), { recursive: true }); + } catch {} + try { + rmdirSync(testDir); + } catch {} + }); + + describe('provisionStagingEnvironment', () => { + it('creates a staging environment on success', async () => { + mockFetchStagingCredentials.mockResolvedValueOnce({ + clientId: 'client_staging_123', + apiKey: 'sk_test_staging_abc', + }); + + const result = await provisionStagingEnvironment('access_token_xyz'); + + expect(result).toBe(true); + expect(mockFetchStagingCredentials).toHaveBeenCalledWith('access_token_xyz'); + + const config = getConfig(); + expect(config).not.toBeNull(); + expect(config?.environments['staging']).toEqual({ + name: 'staging', + type: 'sandbox', + apiKey: 'sk_test_staging_abc', + clientId: 'client_staging_123', + }); + }); + + it('sets staging as active environment when no environments exist', async () => { + mockFetchStagingCredentials.mockResolvedValueOnce({ + clientId: 'client_123', + apiKey: 'sk_test_abc', + }); + + await provisionStagingEnvironment('token'); + + const config = getConfig(); + expect(config?.activeEnvironment).toBe('staging'); + }); + + it('does not change active environment when one already exists', async () => { + // Pre-create an environment + const { saveConfig } = await import('../lib/config-store.js'); + saveConfig({ + activeEnvironment: 'production', + environments: { + production: { + name: 'production', + type: 'production', + apiKey: 'sk_live_existing', + }, + }, + }); + + mockFetchStagingCredentials.mockResolvedValueOnce({ + clientId: 'client_123', + apiKey: 'sk_test_abc', + }); + + await provisionStagingEnvironment('token'); + + const config = getConfig(); + expect(config?.activeEnvironment).toBe('production'); + expect(config?.environments['staging']).toBeDefined(); + expect(config?.environments['production']).toBeDefined(); + }); + + it('updates existing staging environment if already present', async () => { + const { saveConfig } = await import('../lib/config-store.js'); + saveConfig({ + activeEnvironment: 'staging', + environments: { + staging: { + name: 'staging', + type: 'sandbox', + apiKey: 'sk_test_old', + clientId: 'client_old', + }, + }, + }); + + mockFetchStagingCredentials.mockResolvedValueOnce({ + clientId: 'client_new', + apiKey: 'sk_test_new', + }); + + const result = await provisionStagingEnvironment('token'); + + expect(result).toBe(true); + const config = getConfig(); + expect(config?.environments['staging']?.apiKey).toBe('sk_test_new'); + expect(config?.environments['staging']?.clientId).toBe('client_new'); + }); + + it('returns false and does not throw on API 403 error', async () => { + mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Access denied')); + + const result = await provisionStagingEnvironment('token'); + + expect(result).toBe(false); + const config = getConfig(); + expect(config).toBeNull(); + }); + + it('returns false and does not throw on API 404 error', async () => { + mockFetchStagingCredentials.mockRejectedValueOnce(new Error('No staging environment found')); + + const result = await provisionStagingEnvironment('token'); + + expect(result).toBe(false); + }); + + it('returns false and does not throw on network error', async () => { + mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Network error')); + + const result = await provisionStagingEnvironment('token'); + + expect(result).toBe(false); + }); + + it('returns false and does not throw on timeout', async () => { + mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Request timed out')); + + const result = await provisionStagingEnvironment('token'); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/commands/login.ts b/src/commands/login.ts index 6c612b0..e1d0efa 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -4,7 +4,10 @@ import clack from '../utils/clack.js'; import { saveCredentials, getCredentials, getAccessToken, isTokenExpired, updateTokens } from '../lib/credentials.js'; import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js'; import { refreshAccessToken } from '../lib/token-refresh-client.js'; -import { logInfo } from '../utils/debug.js'; +import { logInfo, logError } from '../utils/debug.js'; +import { fetchStagingCredentials } from '../lib/staging-api.js'; +import { getConfig, saveConfig } from '../lib/config-store.js'; +import type { CliConfig } from '../lib/config-store.js'; /** * Parse JWT payload @@ -66,6 +69,40 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Auto-provision a staging environment after login. + * + * Fetches staging credentials using the access token, then saves them + * as a "staging" environment in the config store. Non-fatal — logs a + * hint on failure instead of throwing. + */ +export async function provisionStagingEnvironment(accessToken: string): Promise { + try { + const staging = await fetchStagingCredentials(accessToken); + + const config: CliConfig = getConfig() ?? { environments: {} }; + const isFirst = Object.keys(config.environments).length === 0; + + config.environments['staging'] = { + name: 'staging', + type: 'sandbox', + apiKey: staging.apiKey, + clientId: staging.clientId, + }; + + if (isFirst || !config.activeEnvironment) { + config.activeEnvironment = 'staging'; + } + + saveConfig(config); + logInfo('[login] Staging environment auto-provisioned'); + return true; + } catch (error) { + logError('[login] Failed to auto-provision staging environment:', error instanceof Error ? error.message : error); + return false; + } +} + export async function runLogin(): Promise { const clientId = getCliAuthClientId(); @@ -184,6 +221,14 @@ export async function runLogin(): Promise { spinner.stop('Authentication successful!'); clack.log.success(`Logged in as ${email || userId}`); clack.log.info(`Token expires in ${expiresInSec} seconds`); + + // Auto-provision staging environment + const provisioned = await provisionStagingEnvironment(result.access_token); + if (provisioned) { + clack.log.success('Staging environment configured automatically'); + } else { + clack.log.info(chalk.dim('Run `workos env add` to configure an environment manually')); + } return; } From 49af0f5b843d8848843f6c3b16c10364902e7c73 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 10 Mar 2026 11:09:57 -0500 Subject: [PATCH 2/2] chore: remove .case-tested --- .case-tested | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .case-tested diff --git a/.case-tested b/.case-tested deleted file mode 100644 index ab151a7..0000000 --- a/.case-tested +++ /dev/null @@ -1,4 +0,0 @@ -timestamp: 2026-03-09T20:22:27Z -output_hash: 69a44538eaa013a63ad9b46ab32a6f74770ad96a0e1866e2b13ba06fbebafa92 -pass_indicators: 76 -fail_indicators: 18