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, + }, + ], + }, ], }, {