From f3452cbee381c104f6ceaf66ca38d17a32952e24 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 7 Jul 2026 18:13:08 -0500 Subject: [PATCH] feat: add env provision command and login mismatch safety Add `workos env provision`, a credentials-only subcommand that calls provisionUnclaimedEnvironment() directly (no auth, no code-gen). It emits credentials on stdout (JSON is the agent credential channel), stores the result as a local active unclaimed env so a follow-up `env claim` works, and never writes to the project directory or any .env file. Failures (incl. 429) surface as structured errors with no fallback to login. Rework auth login staging provisioning to stop silent cross-account switches. provisionStagingEnvironment now takes the authenticated account, detects a mismatch against the prior active env (ownerEmail wins over clientId), and on mismatch writes the new account's Staging under a distinct key instead of clobbering the active slot. runLogin prompts (human) / warns (agent-CI) / emits structured JSON, and always ends with an explicit "Now using" line. Stretch: persist ownerEmail/ownerUserId on environment records and carry them through markEnvironmentClaimed; add setActiveEnvironment(). Regression tests added first and confirmed failing on pre-fix code (env provision, mismatch/in-place-clobber guard, "Now using", owner fields). --- src/bin.ts | 11 ++ src/commands/env.spec.ts | 119 +++++++++++++++++- src/commands/env.ts | 66 ++++++++++ src/commands/login.spec.ts | 230 +++++++++++++++++++++++++++++++---- src/commands/login.ts | 146 +++++++++++++++++++--- src/lib/config-store.spec.ts | 85 +++++++++++++ src/lib/config-store.ts | 19 +++ src/utils/help-json.spec.ts | 2 +- src/utils/help-json.ts | 14 +++ 9 files changed, 647 insertions(+), 45 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 1d889a22..3b8f8cda 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -668,6 +668,17 @@ async function runCli(): Promise { await runClaim(); }, ); + registerSubcommand( + yargs, + 'provision', + 'Provision a new unclaimed WorkOS environment (credentials only, no code changes)', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runEnvProvision } = await import('./commands/env.js'); + await runEnvProvision(); + }, + ); return yargs.demandCommand(1, 'Please specify an env subcommand').strict(); }) .command( diff --git a/src/commands/env.spec.ts b/src/commands/env.spec.ts index 1234d06a..fdc215cb 100644 --- a/src/commands/env.spec.ts +++ b/src/commands/env.spec.ts @@ -25,6 +25,16 @@ vi.mock('../utils/clack.js', () => ({ }, })); +// Partial-mock the unclaimed-env API so we control provisioning outcomes but +// keep the real UnclaimedEnvApiError class for instanceof checks. +vi.mock('../lib/unclaimed-env-api.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, provisionUnclaimedEnvironment: vi.fn() }; +}); + +// Guard: runEnvProvision must NEVER write project .env files. +vi.mock('../lib/env-writer.js', () => ({ writeCredentialsEnv: vi.fn() })); + let testDir: string; vi.mock('node:os', async (importOriginal) => { @@ -40,7 +50,9 @@ vi.mock('node:os', async (importOriginal) => { }); const { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); -const { runEnvAdd, runEnvRemove, runEnvSwitch, runEnvList } = await import('./env.js'); +const { runEnvAdd, runEnvRemove, runEnvSwitch, runEnvList, runEnvProvision } = await import('./env.js'); +const { provisionUnclaimedEnvironment, UnclaimedEnvApiError } = await import('../lib/unclaimed-env-api.js'); +const { writeCredentialsEnv } = await import('../lib/env-writer.js'); const { setOutputMode } = await import('../utils/output.js'); const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); const { CliExit } = await import('../utils/cli-exit.js'); @@ -254,6 +266,111 @@ describe('env commands', () => { }); }); + describe('runEnvProvision', () => { + const CREDS = { + clientId: 'client_x', + apiKey: 'sk_test_x', + claimToken: 'ct_x', + authkitDomain: 'foo.authkit.app', + }; + + let consoleOutput: string[]; + let logSpy: ReturnType; + + beforeEach(() => { + vi.mocked(provisionUnclaimedEnvironment).mockReset(); + vi.mocked(writeCredentialsEnv).mockReset(); + consoleOutput = []; + logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + setOutputMode('human'); + }); + + it('emits the provisioned credentials as JSON (agent credential delivery)', async () => { + setOutputMode('json'); + vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS); + + await runEnvProvision(); + + const out = JSON.parse(consoleOutput[0]); + expect(out.status).toBe('ok'); + expect(out.data.apiKey).toBe('sk_test_x'); + expect(out.data.clientId).toBe('client_x'); + expect(out.data.claimToken).toBe('ct_x'); + expect(out.data.authkitDomain).toBe('foo.authkit.app'); + }); + + it('persists the provisioned env locally as the active unclaimed env', async () => { + setOutputMode('json'); + vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS); + + await runEnvProvision(); + + const config = getConfig(); + const env = config?.environments.unclaimed; + expect(env?.type).toBe('unclaimed'); + expect(env?.clientId).toBe('client_x'); + expect((env as { claimToken?: string } | undefined)?.claimToken).toBe('ct_x'); + expect(config?.activeEnvironment).toBe('unclaimed'); + }); + + it('never writes a project .env file', async () => { + setOutputMode('json'); + vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS); + + await runEnvProvision(); + + expect(writeCredentialsEnv).not.toHaveBeenCalled(); + }); + + it('surfaces a 429 as a structured rate_limited error — no config write, no login fallback', async () => { + setOutputMode('json'); + setInteractionMode({ mode: 'agent', source: 'env' }); + vi.mocked(provisionUnclaimedEnvironment).mockRejectedValue( + new UnclaimedEnvApiError('Rate limited. Please wait a moment and try again.', 429), + ); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(runEnvProvision()).rejects.toThrow(CliExit); + const parsed = JSON.parse(String(errorSpy.mock.calls[0][0])); + expect(parsed.error.code).toBe('rate_limited'); + expect(getConfig()).toBeNull(); + expect(writeCredentialsEnv).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + + it('maps an unexpected non-API error to provision_failed', async () => { + setOutputMode('json'); + setInteractionMode({ mode: 'agent', source: 'env' }); + vi.mocked(provisionUnclaimedEnvironment).mockRejectedValue(new Error('boom')); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await expect(runEnvProvision()).rejects.toThrow(CliExit); + const parsed = JSON.parse(String(errorSpy.mock.calls[0][0])); + expect(parsed.error.code).toBe('provision_failed'); + } finally { + errorSpy.mockRestore(); + } + }); + + it('prints the credentials and the env-claim hint in human mode', async () => { + setOutputMode('human'); + vi.mocked(provisionUnclaimedEnvironment).mockResolvedValue(CREDS); + + await runEnvProvision(); + + expect(consoleOutput.join('\n')).toContain('sk_test_x'); + expect(clack.log.info).toHaveBeenCalledWith(expect.stringContaining('env claim')); + }); + }); + describe('JSON output mode', () => { let consoleOutput: string[]; diff --git a/src/commands/env.ts b/src/commands/env.ts index 0da28d69..90cd8137 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -7,6 +7,11 @@ import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mod import { missingArgsRecovery } from '../utils/recovery-hints.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; +import { + provisionUnclaimedEnvironment, + UnclaimedEnvApiError, + type UnclaimedEnvProvisionResult, +} from '../lib/unclaimed-env-api.js'; const ENV_NAME_REGEX = /^[a-z0-9\-_]+$/; @@ -119,6 +124,67 @@ export async function runEnvAdd(options: { outputSuccess('Environment added', { name: name!, type, active: isFirst }); } +/** + * `workos env provision` — provision a fresh unclaimed environment (credentials only). + * + * Calls the low-level `provisionUnclaimedEnvironment()` directly (no auth, no + * code-gen). Credentials are delivered on stdout (JSON is the agent credential + * channel) and persisted locally as an unclaimed env so a follow-up + * `env claim` works. NEVER writes to the project directory or any `.env` file, + * and NEVER falls back to an interactive login on failure. + */ +export async function runEnvProvision(): Promise { + let result: UnclaimedEnvProvisionResult; + try { + result = await provisionUnclaimedEnvironment(); + } catch (error) { + if (error instanceof UnclaimedEnvApiError) { + exitWithError({ + code: error.statusCode === 429 ? 'rate_limited' : 'provision_failed', + message: error.message, + ...(error.statusCode && { apiContext: { status: error.statusCode } }), + }); + } + const message = error instanceof Error ? error.message : 'Unknown error'; + exitWithError({ code: 'provision_failed', message: `Failed to provision environment: ${message}` }); + } + + // Persist as an unclaimed env (parity with install) — NEVER writes to the project dir. + const config = getOrCreateConfig(); + config.environments['unclaimed'] = { + name: 'unclaimed', + type: 'unclaimed', + apiKey: result.apiKey, + clientId: result.clientId, + claimToken: result.claimToken, + }; + config.activeEnvironment = 'unclaimed'; + saveConfig(config); + + if (isJsonMode()) { + outputSuccess('Environment provisioned', { + name: 'unclaimed', + type: 'unclaimed', + active: true, + apiKey: result.apiKey, + clientId: result.clientId, + claimToken: result.claimToken, + authkitDomain: result.authkitDomain, + }); + return; + } + + clack.log.success('Provisioned a new WorkOS environment'); + console.log(''); + console.log(` ${chalk.dim('API key')} ${result.apiKey}`); + console.log(` ${chalk.dim('Client ID')} ${result.clientId}`); + console.log(` ${chalk.dim('AuthKit')} ${result.authkitDomain}`); + console.log(''); + clack.log.info( + `Set as active environment. Run \`${formatWorkOSCommand('env claim')}\` to link it to your account (permanent).`, + ); +} + export async function runEnvRemove(name: string): Promise { const config = getConfig(); if (!config || Object.keys(config.environments).length === 0) { diff --git a/src/commands/login.spec.ts b/src/commands/login.spec.ts index ef6fb9bd..7bdb45c7 100644 --- a/src/commands/login.spec.ts +++ b/src/commands/login.spec.ts @@ -37,11 +37,13 @@ vi.mock('../utils/clack.js', () => ({ error: vi.fn(), info: vi.fn(), step: vi.fn(), + warn: vi.fn(), }, spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), })), + confirm: vi.fn(), isCancel: vi.fn(() => false), }, })); @@ -60,6 +62,7 @@ vi.mock('./install-skill.js', () => ({ vi.mock('../utils/output.js', () => ({ isJsonMode: vi.fn(() => false), exitWithError: vi.fn(), + outputJson: vi.fn(), })); let testDir: string; @@ -76,10 +79,10 @@ vi.mock('node:os', async (importOriginal) => { }; }); -const { getConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); +const { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await import('../lib/config-store.js'); const { provisionStagingEnvironment, installSkillsAfterLogin, runLogin } = await import('./login.js'); const { autoInstallSkills } = await import('./install-skill.js'); -const { isJsonMode } = await import('../utils/output.js'); +const { isJsonMode, outputJson } = await import('../utils/output.js'); const { clearCredentials, setInsecureStorage } = await import('../lib/credentials.js'); const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); const clackMod = await import('../utils/clack.js'); @@ -128,18 +131,20 @@ describe('login', () => { apiKey: 'sk_test_staging_abc', }); - const result = await provisionStagingEnvironment('access_token_xyz'); + const result = await provisionStagingEnvironment('access_token_xyz', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(true); + expect(result.provisioned).toBe(true); expect(mockFetchStagingCredentials).toHaveBeenCalledWith('access_token_xyz'); const config = getConfig(); expect(config).not.toBeNull(); - expect(config?.environments['staging']).toEqual({ + expect(config?.environments['staging']).toMatchObject({ name: 'staging', type: 'sandbox', apiKey: 'sk_test_staging_abc', clientId: 'client_staging_123', + ownerEmail: 'a@example.com', + ownerUserId: 'u1', }); }); @@ -149,7 +154,7 @@ describe('login', () => { apiKey: 'sk_test_abc', }); - await provisionStagingEnvironment('token'); + await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); const config = getConfig(); expect(config?.activeEnvironment).toBe('staging'); @@ -174,7 +179,7 @@ describe('login', () => { apiKey: 'sk_test_abc', }); - await provisionStagingEnvironment('token'); + await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); const config = getConfig(); expect(config?.activeEnvironment).toBe('production'); @@ -182,8 +187,9 @@ describe('login', () => { expect(config?.environments['production']).toBeDefined(); }); - it('updates existing staging environment if already present', async () => { - const { saveConfig } = await import('../lib/config-store.js'); + it('updates existing staging environment in place on re-login as the same account', async () => { + // Same account (email match) — even a reissued clientId updates in place, + // never spawning a staging-2. ownerEmail is what identifies "same account". saveConfig({ activeEnvironment: 'staging', environments: { @@ -192,6 +198,8 @@ describe('login', () => { type: 'sandbox', apiKey: 'sk_test_old', clientId: 'client_old', + ownerEmail: 'a@example.com', + ownerUserId: 'u1', }, }, }); @@ -201,46 +209,142 @@ describe('login', () => { apiKey: 'sk_test_new', }); - const result = await provisionStagingEnvironment('token'); + const result = await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(true); + expect(result.provisioned).toBe(true); + expect(result.mismatch).toBe(false); const config = getConfig(); + expect(config?.environments['staging-2']).toBeUndefined(); expect(config?.environments['staging']?.apiKey).toBe('sk_test_new'); expect(config?.environments['staging']?.clientId).toBe('client_new'); + expect(config?.environments['staging']?.ownerEmail).toBe('a@example.com'); + expect(config?.environments['staging']?.ownerUserId).toBe('u1'); }); - it('returns false and does not throw on API 403 error', async () => { + it('returns not-provisioned and does not throw on API 403 error', async () => { mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Access denied')); - const result = await provisionStagingEnvironment('token'); + const result = await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(false); + expect(result.provisioned).toBe(false); const config = getConfig(); expect(config).toBeNull(); }); - it('returns false and does not throw on API 404 error', async () => { + it('returns not-provisioned and does not throw on API 404 error', async () => { mockFetchStagingCredentials.mockRejectedValueOnce(new Error('No staging environment found')); - const result = await provisionStagingEnvironment('token'); + const result = await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(false); + expect(result.provisioned).toBe(false); }); - it('returns false and does not throw on network error', async () => { + it('returns not-provisioned and does not throw on network error', async () => { mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Network error')); - const result = await provisionStagingEnvironment('token'); + const result = await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(false); + expect(result.provisioned).toBe(false); }); - it('returns false and does not throw on timeout', async () => { + it('returns not-provisioned and does not throw on timeout', async () => { mockFetchStagingCredentials.mockRejectedValueOnce(new Error('Request timed out')); - const result = await provisionStagingEnvironment('token'); + const result = await provisionStagingEnvironment('token', { userId: 'u1', email: 'a@example.com' }); - expect(result).toBe(false); + expect(result.provisioned).toBe(false); + }); + }); + + describe('provisionStagingEnvironment mismatch', () => { + it('stamps ownerEmail and ownerUserId on the provisioned env', async () => { + mockFetchStagingCredentials.mockResolvedValueOnce({ clientId: 'client_gmail', apiKey: 'sk_test' }); + + await provisionStagingEnvironment('token', { email: 'g@x.com', userId: 'u1' }); + + const config = getConfig(); + expect(config?.environments['staging']?.ownerEmail).toBe('g@x.com'); + expect(config?.environments['staging']?.ownerUserId).toBe('u1'); + }); + + it('adds a second staging env without repointing when the active claimed env is a different account', async () => { + saveConfig({ + activeEnvironment: 'sandbox', + environments: { + sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_akshay' }, + }, + }); + mockFetchStagingCredentials.mockResolvedValueOnce({ clientId: 'client_gmail', apiKey: 'sk_test' }); + + const result = await provisionStagingEnvironment('token', { email: 'g@x.com', userId: 'u1' }); + + expect(result.mismatch).toBe(true); + expect(result.switched).toBe(false); + const config = getConfig(); + expect(config?.activeEnvironment).toBe('sandbox'); // preserved + expect(config?.environments['staging']?.clientId).toBe('client_gmail'); + }); + + it('never clobbers a different account already in the staging slot (in-place-clobber guard)', async () => { + saveConfig({ + activeEnvironment: 'staging', + environments: { + staging: { + name: 'staging', + type: 'sandbox', + apiKey: 'sk', + clientId: 'client_akshay', + ownerEmail: 'akshay@x.com', + }, + }, + }); + mockFetchStagingCredentials.mockResolvedValueOnce({ clientId: 'client_gmail', apiKey: 'sk_test' }); + + const result = await provisionStagingEnvironment('token', { email: 'g@x.com', userId: 'u1' }); + + const config = getConfig(); + // The original staging slot is untouched — pre-fix code overwrote it here. + expect(config?.environments['staging']?.clientId).toBe('client_akshay'); + expect(config?.environments['staging-2']?.clientId).toBe('client_gmail'); + expect(config?.activeEnvironment).toBe('staging'); + expect(result.mismatch).toBe(true); + expect(result.envName).toBe('staging-2'); + }); + + it('updates in place with no mismatch when the same account logs in again', async () => { + saveConfig({ + activeEnvironment: 'staging', + environments: { + staging: { + name: 'staging', + type: 'sandbox', + apiKey: 'sk_old', + clientId: 'client_gmail', + ownerEmail: 'g@x.com', + }, + }, + }); + mockFetchStagingCredentials.mockResolvedValueOnce({ clientId: 'client_gmail', apiKey: 'sk_new' }); + + const result = await provisionStagingEnvironment('token', { email: 'g@x.com', userId: 'u1' }); + + expect(result.mismatch).toBe(false); + const config = getConfig(); + expect(config?.environments['staging-2']).toBeUndefined(); + expect(config?.environments['staging']?.apiKey).toBe('sk_new'); + expect(config?.activeEnvironment).toBe('staging'); + }); + + it('auto-assigns active on first login onto an empty config', async () => { + mockFetchStagingCredentials.mockResolvedValueOnce({ clientId: 'client_gmail', apiKey: 'sk_test' }); + + const result = await provisionStagingEnvironment('token', { email: 'g@x.com', userId: 'u1' }); + + const config = getConfig(); + expect(config?.activeEnvironment).toBe('staging'); + expect(config?.environments['staging']?.ownerEmail).toBe('g@x.com'); + expect(result.mismatch).toBe(false); + expect(result.switched).toBe(true); }); }); @@ -265,6 +369,86 @@ describe('login', () => { expect(mockOpen).toHaveBeenCalledWith('https://auth.example.com/device?code=ABCD', { wait: false }); expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('manual URL')); }); + + it('prints a "Now using" line naming the account in human mode', async () => { + setInteractionMode({ mode: 'human', source: 'env' }); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); + + await runLogin(); + + const successSpy = vi.mocked(clackMod.default.log.success); + const nowUsing = successSpy.mock.calls.map((c) => String(c[0])).find((m) => m.includes('Now using')); + expect(nowUsing).toBeDefined(); + expect(nowUsing).toContain('user@example.com'); + }); + + it('switches the active env when the user confirms on a cross-account login', async () => { + setInteractionMode({ mode: 'human', source: 'env' }); + saveConfig({ + activeEnvironment: 'sandbox', + environments: { sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_other' } }, + }); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); + vi.mocked(clackMod.default.confirm).mockResolvedValueOnce(true); + + await runLogin(); + + expect(getConfig()?.activeEnvironment).toBe('staging'); + }); + + it('keeps the prior active env when the user declines the switch', async () => { + setInteractionMode({ mode: 'human', source: 'env' }); + saveConfig({ + activeEnvironment: 'sandbox', + environments: { sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_other' } }, + }); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); + vi.mocked(clackMod.default.confirm).mockResolvedValueOnce(false); + + await runLogin(); + + expect(getConfig()?.activeEnvironment).toBe('sandbox'); + }); + + it('warns and never prompts on a cross-account login in agent mode', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + saveConfig({ + activeEnvironment: 'sandbox', + environments: { sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_other' } }, + }); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); + + await runLogin(); + + expect(clackMod.default.confirm).not.toHaveBeenCalled(); + expect(getConfig()?.activeEnvironment).toBe('sandbox'); + const warned = vi + .mocked(clackMod.default.log.warn) + .mock.calls.map((c) => String(c[0])) + .join('\n'); + expect(warned).toContain('sandbox'); + expect(warned).toContain('user@example.com'); + }); + + it('emits structured provisioning fields and never prompts in JSON mode', async () => { + vi.mocked(isJsonMode).mockReturnValueOnce(true); + saveConfig({ + activeEnvironment: 'sandbox', + environments: { sandbox: { name: 'sandbox', type: 'sandbox', apiKey: 'sk', clientId: 'client_other' } }, + }); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); + + await runLogin(); + + expect(clackMod.default.confirm).not.toHaveBeenCalled(); + expect(outputJson).toHaveBeenCalledWith( + expect.objectContaining({ + mismatch: true, + activeEnvironment: 'sandbox', + account: expect.objectContaining({ email: 'user@example.com' }), + }), + ); + }); }); describe('installSkillsAfterLogin', () => { diff --git a/src/commands/login.ts b/src/commands/login.ts index d90e2e4b..816a6aef 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -6,12 +6,12 @@ import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js'; import { refreshAccessToken } from '../lib/token-refresh-client.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'; +import { getConfig, saveConfig, getActiveEnvironment, setActiveEnvironment } from '../lib/config-store.js'; +import type { CliConfig, EnvironmentConfig } from '../lib/config-store.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { autoInstallSkills } from './install-skill.js'; -import { isJsonMode } from '../utils/output.js'; -import { isAgentMode, isCiMode } from '../utils/interaction-mode.js'; +import { isJsonMode, outputJson } from '../utils/output.js'; +import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; import { ExitCode, exitWithAuthRequired, exitWithCode } from '../utils/exit-codes.js'; import { requestDeviceCode, pollForToken, DeviceAuthTimeoutError } from '../lib/device-auth.js'; import { observeHostFailure } from '../lib/host-probe.js'; @@ -50,37 +50,113 @@ export async function installSkillsAfterLogin(): Promise { } } +/** + * Result of a post-login staging provision. Carries enough context for + * `runLogin` to detect a cross-account switch and decide how to surface it + * without ever silently repointing the active environment. + */ +export interface StagingProvisionResult { + provisioned: boolean; + account: { email?: string; userId: string }; + /** Active env name AFTER provisioning (unchanged unless there was no prior active env). */ + activeEnvironment?: string; + /** Key the new Staging env was written under ('staging' or a fresh 'staging-N'). */ + envName?: string; + envType?: EnvironmentConfig['type']; + mismatch: boolean; + /** True only when provisioning repointed the active env (the no-prior-active case). */ + switched: boolean; + priorEnvName?: string; + priorAccount?: { email?: string; clientId?: string }; +} + +/** + * Does the just-authenticated account differ from the account that owns `prior`? + * + * Email comparison wins when both sides have it (human-readable, survives a + * clientId reissue); otherwise fall back to clientId. When neither is available + * we cannot tell — return false and let the always-on "Now using" line protect + * the user. + */ +function isMismatch( + prior: EnvironmentConfig | undefined, + account: { email?: string }, + staging: { clientId: string }, +): boolean { + if (!prior) return false; + if (prior.ownerEmail && account.email) return prior.ownerEmail !== account.email; + if (prior.clientId) return prior.clientId !== staging.clientId; + return false; +} + +/** Pick a non-colliding key for a new Staging env: 'staging', else 'staging-2', 'staging-3', … */ +function freshStagingKey(config: CliConfig): string { + if (!config.environments['staging']) return 'staging'; + let i = 2; + while (config.environments[`staging-${i}`]) i++; + return `staging-${i}`; +} + /** * 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. + * Fetches staging credentials for the just-authenticated account and stores + * them. On a detected cross-account mismatch, the new account's Staging is + * written under a DISTINCT key rather than clobbering the active slot, and the + * active pointer is left alone — only a login onto an empty config auto-assigns + * the active env. Non-fatal — logs a hint on failure instead of throwing. */ -export async function provisionStagingEnvironment(accessToken: string): Promise { +export async function provisionStagingEnvironment( + accessToken: string, + account: { email?: string; userId: 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', + const priorName = config.activeEnvironment; + const prior = priorName ? config.environments[priorName] : undefined; // capture BEFORE write + const mismatch = isMismatch(prior, account, staging); + + // Never overwrite a DIFFERENT account's 'staging' slot in place. + const stagingSlot = config.environments['staging']; + const slotMismatch = isMismatch(stagingSlot, account, staging); + const key = slotMismatch ? freshStagingKey(config) : 'staging'; + + config.environments[key] = { + name: key, type: 'sandbox', apiKey: staging.apiKey, clientId: staging.clientId, + ...(account.email && { ownerEmail: account.email }), + ownerUserId: account.userId, }; - if (isFirst || !config.activeEnvironment) { - config.activeEnvironment = 'staging'; + // Only auto-assign active when there is NO valid prior active env. + let switched = false; + if (!prior) { + config.activeEnvironment = key; + switched = true; } saveConfig(config); - logInfo('[login] Staging environment auto-provisioned'); - return true; + logInfo('[login] Staging environment provisioned'); + + return { + provisioned: true, + account, + activeEnvironment: config.activeEnvironment, + envName: key, + envType: 'sandbox', + mismatch, + switched, + priorEnvName: priorName, + priorAccount: prior ? { email: prior.ownerEmail, clientId: prior.clientId } : undefined, + }; } catch (error) { - logError('[login] Failed to auto-provision staging environment:', error instanceof Error ? error.message : error); - return false; + logError('[login] Failed to provision staging environment:', error instanceof Error ? error.message : error); + return { provisioned: false, account, mismatch: false, switched: false }; } } @@ -176,9 +252,39 @@ export async function runLogin(): Promise { clack.log.success(`Logged in as ${result.email || result.userId}`); clack.log.info(`Token expires in ${expiresInSec} seconds`); - const provisioned = await provisionStagingEnvironment(result.accessToken); - if (provisioned) { - clack.log.success('Staging environment configured automatically'); + const account = { email: result.email, userId: result.userId }; + const provision = await provisionStagingEnvironment(result.accessToken, account); + + if (isJsonMode()) { + outputJson({ + status: 'ok', + account: { email: account.email ?? null, userId: account.userId }, + activeEnvironment: provision.activeEnvironment ?? null, + mismatch: provision.mismatch, + }); + } else if (provision.provisioned) { + if (provision.mismatch) { + const priorLabel = provision.priorAccount?.email ?? provision.priorAccount?.clientId ?? provision.priorEnvName; + if (isPromptAllowed()) { + const answer = await clack.confirm({ + message: `You were using ${provision.priorEnvName} (${priorLabel}, a different account). Switch active environment to ${account.email ?? account.userId}'s Staging?`, + initialValue: false, // default: keep current + }); + if (!clack.isCancel(answer) && answer && provision.envName) { + setActiveEnvironment(provision.envName); + } + } else { + clack.log.warn( + `Logged in as ${account.email ?? account.userId}, but the active environment "${provision.priorEnvName}" belongs to a different account (${priorLabel}). Keeping it active. Run \`${formatWorkOSCommand('env switch')}\` to change environments.`, + ); + } + } + const active = getActiveEnvironment(); + if (active) { + clack.log.success(`Now using: ${active.name} (${active.type}) — ${account.email ?? account.userId}`); + } else { + clack.log.info(chalk.dim(`Run \`${formatWorkOSCommand('env add')}\` to configure an environment manually`)); + } } else { clack.log.info(chalk.dim(`Run \`${formatWorkOSCommand('env add')}\` to configure an environment manually`)); } diff --git a/src/lib/config-store.spec.ts b/src/lib/config-store.spec.ts index b44acafa..ced52314 100644 --- a/src/lib/config-store.spec.ts +++ b/src/lib/config-store.spec.ts @@ -433,6 +433,47 @@ describe('config-store', () => { }); }); + describe('owner identity fields', () => { + it('round-trips ownerEmail and ownerUserId through file storage', () => { + saveConfig({ + activeEnvironment: 'staging', + environments: { + staging: { + name: 'staging', + type: 'sandbox', + apiKey: 'sk_test', + clientId: 'client_x', + ownerEmail: 'a@example.com', + ownerUserId: 'u1', + }, + }, + }); + const env = getConfig()?.environments['staging']; + expect(env?.ownerEmail).toBe('a@example.com'); + expect(env?.ownerUserId).toBe('u1'); + }); + + it('round-trips owner fields through keyring storage', () => { + setInsecureConfigStorage(false); + saveConfig({ + activeEnvironment: 'staging', + environments: { + staging: { + name: 'staging', + type: 'sandbox', + apiKey: 'sk_test', + clientId: 'client_x', + ownerEmail: 'a@example.com', + ownerUserId: 'u1', + }, + }, + }); + const env = getConfig()?.environments['staging']; + expect(env?.ownerEmail).toBe('a@example.com'); + expect(env?.ownerUserId).toBe('u1'); + }); + }); + describe('markEnvironmentClaimed', () => { it('renames environment from unclaimed to sandbox', () => { saveConfig({ @@ -459,6 +500,50 @@ describe('config-store', () => { expect(config?.activeEnvironment).toBe('sandbox'); }); + it('leaves owner fields undefined when the unclaimed env had none', () => { + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + }, + }, + }); + + markEnvironmentClaimed(); + + const sandbox = getConfig()?.environments['sandbox']; + expect(sandbox?.ownerEmail).toBeUndefined(); + expect(sandbox?.ownerUserId).toBeUndefined(); + }); + + it('carries through owner fields present on the unclaimed env', () => { + saveConfig({ + activeEnvironment: 'unclaimed', + environments: { + unclaimed: { + name: 'unclaimed', + type: 'unclaimed', + apiKey: 'sk_test_xxx', + clientId: 'client_01ABC', + claimToken: 'ct_token', + ownerEmail: 'a@example.com', + ownerUserId: 'u1', + }, + }, + }); + + markEnvironmentClaimed(); + + const sandbox = getConfig()?.environments['sandbox']; + expect(sandbox?.ownerEmail).toBe('a@example.com'); + expect(sandbox?.ownerUserId).toBe('u1'); + }); + it('does nothing when no config', () => { // No config saved — should not throw expect(() => markEnvironmentClaimed()).not.toThrow(); diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index fa48f623..baef348e 100644 --- a/src/lib/config-store.ts +++ b/src/lib/config-store.ts @@ -20,6 +20,10 @@ interface BaseEnvironmentConfig { name: string; apiKey: string; endpoint?: string; + /** Email of the account that owns this environment. Stamped when `auth login` provisions Staging for a known account; undefined for unclaimed/manually-added envs. */ + ownerEmail?: string; + /** User ID of the account that owns this environment. Stamped alongside ownerEmail. */ + ownerUserId?: string; } export interface ClaimedEnvironmentConfig extends BaseEnvironmentConfig { @@ -235,6 +239,19 @@ export function getActiveEnvironment(): EnvironmentConfig | null { return config.environments[config.activeEnvironment] ?? null; } +/** + * Set the active environment by name. + * + * No-op when there is no config or the named environment does not exist — + * callers should not be able to point `activeEnvironment` at a missing key. + */ +export function setActiveEnvironment(name: string): void { + const config = getConfig(); + if (!config || !config.environments[name]) return; + config.activeEnvironment = name; + saveConfig(config); +} + export function getConfigPath(): string { return getConfigFilePath(); } @@ -301,6 +318,8 @@ export function markEnvironmentClaimed(): void { apiKey: env.apiKey, clientId: env.clientId, ...(env.endpoint && { endpoint: env.endpoint }), + ...(env.ownerEmail && { ownerEmail: env.ownerEmail }), + ...(env.ownerUserId && { ownerUserId: env.ownerUserId }), }; if (oldKey !== newKey) { diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index 6a58c81a..8cf87c0c 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -101,7 +101,7 @@ describe('help-json', () => { const tree = buildCommandTree('env'); expect(tree.name).toBe('env'); const subNames = tree.commands!.map((c) => c.name); - expect(subNames).toEqual(expect.arrayContaining(['add', 'remove', 'switch', 'list'])); + expect(subNames).toEqual(expect.arrayContaining(['add', 'remove', 'switch', 'list', 'claim', 'provision'])); }); it('returns organization subtree with CRUD subcommands', () => { diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index b349d052..8b728ada 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -340,6 +340,20 @@ const commands: CommandSchema[] = [ }, ], }, + { + name: 'provision', + description: 'Provision a new unclaimed WorkOS environment (credentials only, no code changes)', + options: [ + { + name: 'json', + type: 'boolean', + description: 'Output provisioned credentials (apiKey, clientId, claimToken) as JSON', + required: false, + default: false, + hidden: false, + }, + ], + }, ], }, {