From ffeca3907dbb318dfe9ff5a1fc397fc5513191b0 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 26 Aug 2026 09:33:09 -0500 Subject: [PATCH 1/5] feat(install): let the user pick the environment during install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh 'npx workos' silently installs against whatever profile happens to be active — 'staging-3' says nothing about which dashboard environment the project is about to be wired to. When more than one stored profile has an API key (and prompting is allowed), the installer now asks which environment to use, labeled with project-prefixed dashboard names: staging — Nick's Team's Project > test12 Sandbox staging-3 — cli-branding-smoke > Staging (active) Sandbox The choice persists via setActiveEnvironment so the install and every later command agree. Explicit keys (WORKOS_API_KEY / --api-key), non-interactive modes, and single-profile configs never prompt; cancel exits 2 like the installer's other prompts. The picker offers local profiles rather than all team environments: the catalog has no create-key operation, so profiles are the only environments the CLI holds usable credentials for. --- src/lib/resolve-install-credentials.spec.ts | 97 +++++++++++++++++++++ src/lib/resolve-install-credentials.ts | 48 +++++++++- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/src/lib/resolve-install-credentials.spec.ts b/src/lib/resolve-install-credentials.spec.ts index 5bc6026d..cdf84c11 100644 --- a/src/lib/resolve-install-credentials.spec.ts +++ b/src/lib/resolve-install-credentials.spec.ts @@ -6,9 +6,13 @@ import { join } from 'node:path'; // Mock config-store const mockGetActiveEnvironment = vi.fn(); const mockIsUnclaimedEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); +const mockSetActiveEnvironment = vi.fn(); vi.mock('./config-store.js', () => ({ getActiveEnvironment: (...args: unknown[]) => mockGetActiveEnvironment(...args), isUnclaimedEnvironment: (...args: unknown[]) => mockIsUnclaimedEnvironment(...args), + getConfig: () => mockGetConfig(), + setActiveEnvironment: (...args: unknown[]) => mockSetActiveEnvironment(...args), })); // Mock credentials @@ -24,8 +28,12 @@ vi.mock('./unclaimed-env-provision.js', () => ({ })); // Mock the UI facade — the no-clobber branch now explains itself out loud. +const CANCEL = Symbol('cancel'); +const mockSelect = vi.fn(); const mockUi = { log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), step: vi.fn(), success: vi.fn(), hint: vi.fn() }, + select: (...args: unknown[]) => mockSelect(...args), + isCancel: (value: unknown) => value === CANCEL, }; vi.mock('../utils/ui.js', () => ({ default: mockUi })); @@ -43,6 +51,7 @@ describe('resolveInstallCredentials', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetConfig.mockReturnValue(null); delete process.env.WORKOS_API_KEY; emptyCwd = mkdtempSync(join(tmpdir(), 'resolve-install-credentials-cwd-')); cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(emptyCwd); @@ -311,4 +320,92 @@ describe('resolveInstallCredentials', () => { expect(mockAuthenticate).toHaveBeenCalled(); }); }); + + describe('environment picker', () => { + const twoProfiles = { + activeEnvironment: 'staging-3', + environments: { + staging: { + name: 'staging', + type: 'sandbox', + apiKey: 'sk_test_a', + environmentName: 'test12', + projectName: "Nick's Team's Project", + }, + 'staging-3': { + name: 'staging-3', + type: 'sandbox', + apiKey: 'sk_test_b', + environmentName: 'Staging', + projectName: 'cli-branding-smoke', + }, + }, + }; + + beforeEach(() => { + mockGetActiveEnvironment.mockReturnValue(twoProfiles.environments['staging-3']); + mockIsUnclaimedEnvironment.mockReturnValue(false); + mockGetAccessToken.mockReturnValue('token_x'); + }); + + it('prompts with project-prefixed labels and persists a different choice', async () => { + mockGetConfig.mockReturnValue(twoProfiles); + mockSelect.mockResolvedValue('staging'); + + await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); + + const call = mockSelect.mock.calls[0][0] as { + options: Array<{ value: string; label: string }>; + initialValue: string; + }; + expect(call.initialValue).toBe('staging-3'); + expect(call.options.map((o) => o.label)).toEqual([ + "staging — Nick's Team's Project > test12", + 'staging-3 — cli-branding-smoke > Staging (active)', + ]); + expect(mockSetActiveEnvironment).toHaveBeenCalledWith('staging'); + }); + + it('keeps the active profile without a config write when it is re-chosen', async () => { + mockGetConfig.mockReturnValue(twoProfiles); + mockSelect.mockResolvedValue('staging-3'); + + await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); + + expect(mockSetActiveEnvironment).not.toHaveBeenCalled(); + }); + + it('never prompts with a single keyed profile', async () => { + mockGetConfig.mockReturnValue({ + activeEnvironment: 'staging-3', + environments: { 'staging-3': twoProfiles.environments['staging-3'] }, + }); + + await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); + + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it('never prompts in non-interactive modes', async () => { + const { setInteractionMode, resetInteractionModeForTests } = await import('../utils/interaction-mode.js'); + setInteractionMode({ mode: 'agent', source: 'env' }); + try { + mockGetConfig.mockReturnValue(twoProfiles); + await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); + expect(mockSelect).not.toHaveBeenCalled(); + } finally { + resetInteractionModeForTests(); + } + }); + + it('cancel cancels the install (exit 2)', async () => { + mockGetConfig.mockReturnValue(twoProfiles); + mockSelect.mockResolvedValue(CANCEL); + + await expect(resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate)).rejects.toMatchObject({ + exitCode: 2, + }); + expect(mockSetActiveEnvironment).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/lib/resolve-install-credentials.ts b/src/lib/resolve-install-credentials.ts index 1c352f8e..d4f2e9c4 100644 --- a/src/lib/resolve-install-credentials.ts +++ b/src/lib/resolve-install-credentials.ts @@ -8,6 +8,52 @@ * - Logged-in user: API key + OAuth token (credential proxy handles gateway) * - Direct mode: not handled here (resolved in agent-interface.ts via ANTHROPIC_API_KEY) */ +import type { EnvironmentConfig } from './config-store.js'; + +/** + * When several stored profiles could serve this install, ask which WorkOS + * environment to use instead of silently taking the active one — profile + * names like 'staging-3' say nothing about which dashboard environment they + * target, and installs write the chosen credentials into the project. + * + * Never prompts for: explicit keys (handled before this runs), non-interactive + * modes, or configs with fewer than two keyed profiles. Choosing persists via + * setActiveEnvironment so the installer and every later command agree; cancel + * cancels the install (exit 2), matching the installer's other prompts. + */ +async function maybePickInstallEnvironment(activeEnv: EnvironmentConfig | null): Promise { + const { isPromptAllowed } = await import('../utils/interaction-mode.js'); + if (!isPromptAllowed()) return activeEnv; + + const { getConfig, getActiveEnvironment, setActiveEnvironment } = await import('./config-store.js'); + const config = getConfig(); + if (!config) return activeEnv; + const candidates = Object.entries(config.environments).filter(([, env]) => env.apiKey); + if (candidates.length < 2) return activeEnv; + + const ui = (await import('../utils/ui.js')).default; + const { ExitCode, exitWithCode } = await import('../utils/exit-codes.js'); + + const choice = await ui.select({ + message: 'Which WorkOS environment should this install use?', + options: candidates.map(([key, env]) => { + const dashboardName = env.environmentName + ? env.projectName + ? `${env.projectName} > ${env.environmentName}` + : env.environmentName + : env.environmentId; + let label = dashboardName ? `${key} — ${dashboardName}` : key; + if (key === config.activeEnvironment) label += ' (active)'; + const hint = env.type === 'sandbox' ? 'Sandbox' : env.type === 'unclaimed' ? 'Unclaimed' : 'Production'; + return { value: key, label, hint }; + }), + initialValue: config.activeEnvironment, + }); + if (ui.isCancel(choice)) exitWithCode(ExitCode.CANCELLED); + if (choice !== config.activeEnvironment) setActiveEnvironment(String(choice)); + return getActiveEnvironment(); +} + export async function resolveInstallCredentials( apiKey: string | undefined, installDir: string | undefined, @@ -22,7 +68,7 @@ export async function resolveInstallCredentials( try { const { getActiveEnvironment, isUnclaimedEnvironment } = await import('./config-store.js'); const { getAccessToken } = await import('./credentials.js'); - const activeEnv = getActiveEnvironment(); + const activeEnv = await maybePickInstallEnvironment(getActiveEnvironment()); if (activeEnv?.apiKey) { // Has API key — but does it have gateway auth? From 9cc129cf088092afd35335b23ae3b30f1db87028 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 26 Aug 2026 09:44:19 -0500 Subject: [PATCH 2/5] fix(install): guard the environment picker against JSON mode and project-owned keys Two review findings on the picker: - --json on a TTY still counts as prompt-allowed, so ui.select threw PromptUnavailableError instead of completing non-interactively. The picker now also checks isJsonMode(). - A project whose env file already carries WORKOS_API_KEY takes the no-clobber path (key kept, login only). Prompting in that case could persist a picked profile and convert the kept key into an overwrite with a different environment's credentials. The picker now defers to project-owned keys entirely. Addresses PR #230 review. --- src/lib/resolve-install-credentials.spec.ts | 21 +++++++++++++++++++ src/lib/resolve-install-credentials.ts | 23 +++++++++++++++------ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/lib/resolve-install-credentials.spec.ts b/src/lib/resolve-install-credentials.spec.ts index cdf84c11..4eff0843 100644 --- a/src/lib/resolve-install-credentials.spec.ts +++ b/src/lib/resolve-install-credentials.spec.ts @@ -398,6 +398,27 @@ describe('resolveInstallCredentials', () => { } }); + it('never prompts in JSON mode, even on a TTY', async () => { + mockGetConfig.mockReturnValue(twoProfiles); + setOutputMode('json'); + + await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); + + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it('never prompts when the project already carries WORKOS_API_KEY', async () => { + // The no-clobber contract: a project key is kept, so offering a profile + // pick here would set up an overwrite with a different environment's key. + writeFileSync(join(emptyCwd, '.env'), 'WORKOS_API_KEY=sk_project\n'); + mockGetConfig.mockReturnValue(twoProfiles); + + await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockSetActiveEnvironment).not.toHaveBeenCalled(); + }); + it('cancel cancels the install (exit 2)', async () => { mockGetConfig.mockReturnValue(twoProfiles); mockSelect.mockResolvedValue(CANCEL); diff --git a/src/lib/resolve-install-credentials.ts b/src/lib/resolve-install-credentials.ts index d4f2e9c4..ee559a8a 100644 --- a/src/lib/resolve-install-credentials.ts +++ b/src/lib/resolve-install-credentials.ts @@ -17,13 +17,24 @@ import type { EnvironmentConfig } from './config-store.js'; * target, and installs write the chosen credentials into the project. * * Never prompts for: explicit keys (handled before this runs), non-interactive - * modes, or configs with fewer than two keyed profiles. Choosing persists via - * setActiveEnvironment so the installer and every later command agree; cancel - * cancels the install (exit 2), matching the installer's other prompts. + * modes (including --json on a TTY — ui.select would throw), projects that + * already carry their own WORKOS_API_KEY (the no-clobber path keeps the + * project's key; prompting would convert it into an overwrite with a picked + * profile's key), or configs with fewer than two keyed profiles. Choosing + * persists via setActiveEnvironment so the installer and every later command + * agree; cancel cancels the install (exit 2), matching the installer's other + * prompts. */ -async function maybePickInstallEnvironment(activeEnv: EnvironmentConfig | null): Promise { +async function maybePickInstallEnvironment( + activeEnv: EnvironmentConfig | null, + installDir: string, +): Promise { const { isPromptAllowed } = await import('../utils/interaction-mode.js'); - if (!isPromptAllowed()) return activeEnv; + const { isJsonMode } = await import('../utils/output.js'); + if (!isPromptAllowed() || isJsonMode()) return activeEnv; + + const { readProjectEnvCredentials } = await import('./project-env.js'); + if (readProjectEnvCredentials(installDir).apiKey) return activeEnv; const { getConfig, getActiveEnvironment, setActiveEnvironment } = await import('./config-store.js'); const config = getConfig(); @@ -68,7 +79,7 @@ export async function resolveInstallCredentials( try { const { getActiveEnvironment, isUnclaimedEnvironment } = await import('./config-store.js'); const { getAccessToken } = await import('./credentials.js'); - const activeEnv = await maybePickInstallEnvironment(getActiveEnvironment()); + const activeEnv = await maybePickInstallEnvironment(getActiveEnvironment(), installDir ?? process.cwd()); if (activeEnv?.apiKey) { // Has API key — but does it have gateway auth? From ce7252ed2fa4d0e229df4e675b04b9d0d99c236d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 26 Aug 2026 11:48:43 -0500 Subject: [PATCH 3/5] fix(install): keep a project-owned WORKOS_API_KEY through staging credential resolution The picker guard in 0b8fa73 closed the prompt path, but a key-only project (valid WORKOS_API_KEY, no valid WORKOS_CLIENT_ID) still scanned as 'unconfigured' and fell through to the staging-credentials step, which returned the active profile's complete pair -- and configureEnvironment upserted it, silently re-pointing the app at a different environment. resolveStagingCredentials() now owns that step (active profile -> cached staging -> fresh fetch, unchanged priority) and keeps the project's own valid key, adopting only the missing client ID. An invalid project key is treated as absent, matching credential-discovery. The staging cache still records the fetched pair verbatim. Addresses PR #230 review. --- src/lib/installer-core.spec.ts | 2 +- src/lib/installer-core.ts | 3 +- src/lib/resolve-install-credentials.spec.ts | 76 ++++++++++++++++++++- src/lib/resolve-install-credentials.ts | 62 +++++++++++++++++ src/lib/run-with-core.ts | 42 ++---------- 5 files changed, 145 insertions(+), 40 deletions(-) diff --git a/src/lib/installer-core.spec.ts b/src/lib/installer-core.spec.ts index a8b2495d..941c8d82 100644 --- a/src/lib/installer-core.spec.ts +++ b/src/lib/installer-core.spec.ts @@ -442,7 +442,7 @@ describe('InstallerCore State Machine', () => { deviceAuthStarted = true; throw new Error('device auth should not be called'); }), - fetchStagingCredentials: fromPromise(async () => ({ + fetchStagingCredentials: fromPromise(async () => ({ clientId: 'client_unclaimed', apiKey: 'sk_test_unclaimed', })), diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index 88fe6a23..08e797a0 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -390,7 +390,7 @@ export const installerMachine = setup({ >(async () => { throw new Error('runDeviceAuth not implemented - provide via machine.provide()'); }), - fetchStagingCredentials: fromPromise(async () => { + fetchStagingCredentials: fromPromise(async () => { throw new Error('fetchStagingCredentials not implemented - provide via machine.provide()'); }), // Branch check actors @@ -897,6 +897,7 @@ export const installerMachine = setup({ invoke: { id: 'fetchStagingCredentials', src: 'fetchStagingCredentials', + input: ({ context }) => ({ installDir: context.options.installDir }), onDone: { target: '#installer.configuring', actions: [ diff --git a/src/lib/resolve-install-credentials.spec.ts b/src/lib/resolve-install-credentials.spec.ts index 4eff0843..b8f6cc27 100644 --- a/src/lib/resolve-install-credentials.spec.ts +++ b/src/lib/resolve-install-credentials.spec.ts @@ -8,17 +8,29 @@ const mockGetActiveEnvironment = vi.fn(); const mockIsUnclaimedEnvironment = vi.fn(); const mockGetConfig = vi.fn(); const mockSetActiveEnvironment = vi.fn(); +const mockSaveConfig = vi.fn(); vi.mock('./config-store.js', () => ({ getActiveEnvironment: (...args: unknown[]) => mockGetActiveEnvironment(...args), isUnclaimedEnvironment: (...args: unknown[]) => mockIsUnclaimedEnvironment(...args), getConfig: () => mockGetConfig(), setActiveEnvironment: (...args: unknown[]) => mockSetActiveEnvironment(...args), + saveConfig: (...args: unknown[]) => mockSaveConfig(...args), })); // Mock credentials const mockGetAccessToken = vi.fn(); +const mockGetStagingCredentials = vi.fn(); +const mockSaveStagingCredentials = vi.fn(); vi.mock('./credentials.js', () => ({ getAccessToken: () => mockGetAccessToken(), + getStagingCredentials: () => mockGetStagingCredentials(), + saveStagingCredentials: (...args: unknown[]) => mockSaveStagingCredentials(...args), +})); + +// Mock the staging API +const mockFetchStagingCredentials = vi.fn(); +vi.mock('./staging-api.js', () => ({ + fetchStagingCredentials: (...args: unknown[]) => mockFetchStagingCredentials(...args), })); // Mock unclaimed-env-provision @@ -37,7 +49,7 @@ const mockUi = { }; vi.mock('../utils/ui.js', () => ({ default: mockUi })); -const { resolveInstallCredentials } = await import('./resolve-install-credentials.js'); +const { resolveInstallCredentials, resolveStagingCredentials } = await import('./resolve-install-credentials.js'); const { setOutputMode } = await import('../utils/output.js'); describe('resolveInstallCredentials', () => { @@ -429,4 +441,66 @@ describe('resolveInstallCredentials', () => { expect(mockSetActiveEnvironment).not.toHaveBeenCalled(); }); }); + + // The machine-side half of the no-clobber contract: a key-only project scans + // as "no valid credentials" (client ID missing/invalid) and lands in the + // staging-credential step, which must not hand configureEnvironment a + // different environment's key to upsert over the project's own. + describe('resolveStagingCredentials', () => { + let projectDir: string; + + beforeEach(() => { + projectDir = mkdtempSync(join(tmpdir(), 'resolve-staging-credentials-test-')); + mockGetActiveEnvironment.mockReturnValue({ + name: 'staging', + type: 'sandbox', + apiKey: 'sk_test_active_key', + clientId: 'client_01ACTIVE', + }); + mockGetStagingCredentials.mockReturnValue(null); + mockGetAccessToken.mockReturnValue('token_x'); + }); + + afterEach(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + it('returns the active profile pair when the project carries no key', async () => { + const result = await resolveStagingCredentials(projectDir); + + expect(result).toEqual({ clientId: 'client_01ACTIVE', apiKey: 'sk_test_active_key' }); + }); + + it('keeps a valid project WORKOS_API_KEY and adopts only the active profile client ID', async () => { + writeFileSync(join(projectDir, '.env'), 'WORKOS_API_KEY=sk_test_project_key\n'); + + const result = await resolveStagingCredentials(projectDir); + + expect(result).toEqual({ clientId: 'client_01ACTIVE', apiKey: 'sk_test_project_key' }); + }); + + it('keeps the project key on the fresh-fetch path, caching the fetched pair verbatim', async () => { + writeFileSync(join(projectDir, '.env'), 'WORKOS_API_KEY=sk_test_project_key\n'); + mockGetActiveEnvironment.mockReturnValue(null); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_01STAGING', apiKey: 'sk_test_staging_key' }); + + const result = await resolveStagingCredentials(projectDir); + + expect(result).toEqual({ clientId: 'client_01STAGING', apiKey: 'sk_test_project_key' }); + // The cache stays truthful: it records the staging environment's real + // pair, not the project-key mix returned for this install. + expect(mockSaveStagingCredentials).toHaveBeenCalledWith({ + clientId: 'client_01STAGING', + apiKey: 'sk_test_staging_key', + }); + }); + + it('treats an invalid project key as absent, matching credential discovery', async () => { + writeFileSync(join(projectDir, '.env'), 'WORKOS_API_KEY=not-a-real-key\n'); + + const result = await resolveStagingCredentials(projectDir); + + expect(result).toEqual({ clientId: 'client_01ACTIVE', apiKey: 'sk_test_active_key' }); + }); + }); }); diff --git a/src/lib/resolve-install-credentials.ts b/src/lib/resolve-install-credentials.ts index ee559a8a..b4e23a53 100644 --- a/src/lib/resolve-install-credentials.ts +++ b/src/lib/resolve-install-credentials.ts @@ -65,6 +65,68 @@ async function maybePickInstallEnvironment( return getActiveEnvironment(); } +/** + * The install machine's staging-credential step. Source priority: active + * profile -> cached staging pair -> fresh staging fetch (persisted for reuse). + * + * A project that already carries its own valid WORKOS_API_KEY keeps it. The + * preflight no-clobber path is only half the contract: scanning a key-only + * project finds no valid client ID and lands here, so returning the fallback's + * COMPLETE pair would let configureEnvironment upsert a different + * environment's key over the project's. The fallback supplies only the missing + * client ID. An invalid project key is no key -- discovery ignores it too. + */ +export async function resolveStagingCredentials(installDir: string): Promise<{ clientId: string; apiKey: string }> { + const { getActiveEnvironment, getConfig, saveConfig } = await import('./config-store.js'); + const { getAccessToken, getStagingCredentials, saveStagingCredentials } = await import('./credentials.js'); + const { readProjectEnvCredentials } = await import('./project-env.js'); + const { isValidApiKey } = await import('./credential-discovery.js'); + const { logInfo } = await import('../utils/debug.js'); + + const projectKey = readProjectEnvCredentials(installDir).apiKey; + const keepKey = projectKey && isValidApiKey(projectKey) ? projectKey : undefined; + const forProject = ({ clientId, apiKey }: { clientId: string; apiKey: string }) => { + if (!keepKey) return { clientId, apiKey }; + logInfo('[resolve-install-credentials] Project WORKOS_API_KEY kept -- adopting only the client ID'); + return { clientId, apiKey: keepKey }; + }; + + const activeEnv = getActiveEnvironment(); + if (activeEnv?.clientId && activeEnv?.apiKey) { + return forProject({ clientId: activeEnv.clientId, apiKey: activeEnv.apiKey }); + } + + const cached = getStagingCredentials(); + if (cached) return forProject(cached); + + const token = getAccessToken(); + if (!token) throw new Error('No access token available'); + + const { fetchStagingCredentials } = await import('./staging-api.js'); + const staging = await fetchStagingCredentials(token); + saveStagingCredentials(staging); + + try { + const config = getConfig() ?? { environments: {} }; + if (!config.environments['default']) { + config.environments['default'] = { + name: 'default', + type: staging.apiKey.startsWith('sk_test_') ? 'sandbox' : 'production', + apiKey: staging.apiKey, + clientId: staging.clientId, + }; + if (!config.activeEnvironment) { + config.activeEnvironment = 'default'; + } + saveConfig(config); + } + } catch { + // Don't block install if config-store write fails + } + + return forProject(staging); +} + export async function resolveInstallCredentials( apiKey: string | undefined, installDir: string | undefined, diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 64497344..bafafbac 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -25,11 +25,10 @@ import type { Integration } from './constants.js'; import { readProjectEnvCredentials } from './project-env.js'; import { enableDebugLogs, initLogFile, logInfo, logError } from '../utils/debug.js'; -import { getAccessToken, saveCredentials, getStagingCredentials, saveStagingCredentials } from './credentials.js'; -import { getConfig, saveConfig, getActiveEnvironment, isUnclaimedEnvironment } from './config-store.js'; +import { getAccessToken, saveCredentials } from './credentials.js'; +import { getActiveEnvironment, isUnclaimedEnvironment } from './config-store.js'; import { checkForEnvFiles, discoverCredentials } from './credential-discovery.js'; import { requestDeviceCode, pollForToken } from './device-auth.js'; -import { fetchStagingCredentials as fetchStagingCredentialsApi } from './staging-api.js'; import { getCliAuthClientId, getAuthkitDomain } from './settings.js'; import { getTelemetryUrl } from '../utils/urls.js'; import { analytics } from '../utils/analytics.js'; @@ -439,40 +438,9 @@ export async function runWithCore(options: InstallerOptions): Promise { return { result, deviceAuth }; }), - fetchStagingCredentials: fromPromise(async () => { - const activeEnv = getActiveEnvironment(); - if (activeEnv?.clientId && activeEnv?.apiKey) { - return { clientId: activeEnv.clientId, apiKey: activeEnv.apiKey }; - } - - const cached = getStagingCredentials(); - if (cached) return cached; - - const token = getAccessToken(); - if (!token) throw new Error('No access token available'); - - const staging = await fetchStagingCredentialsApi(token); - saveStagingCredentials(staging); - - try { - const config = getConfig() ?? { environments: {} }; - if (!config.environments['default']) { - config.environments['default'] = { - name: 'default', - type: staging.apiKey.startsWith('sk_test_') ? 'sandbox' : 'production', - apiKey: staging.apiKey, - clientId: staging.clientId, - }; - if (!config.activeEnvironment) { - config.activeEnvironment = 'default'; - } - saveConfig(config); - } - } catch { - // Don't block install if config-store write fails - } - - return staging; + fetchStagingCredentials: fromPromise(async ({ input }) => { + const { resolveStagingCredentials } = await import('./resolve-install-credentials.js'); + return resolveStagingCredentials(input.installDir); }), // Branch check actors From 188e9f8c0407f103f33e60a613c57ae02a7f2106 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 26 Aug 2026 13:40:01 -0500 Subject: [PATCH 4/5] fix(install): refuse credential pairing for key-only projects instead of mixing The previous commit kept the project's WORKOS_API_KEY and adopted the fallback's client ID -- which can combine credentials from two different WorkOS environments. No API maps a secret key back to its environment, so after a consented env scan a key-only project now throws out of staging resolution and lands in the machine's manual prompt, where the user supplies the matching pair. Declined scans keep the fallback pair: declining opted the project's env files out. Addresses PR #230 review. --- src/lib/installer-core.spec.ts | 10 +++-- src/lib/installer-core.ts | 10 +++-- src/lib/resolve-install-credentials.spec.ts | 31 ++++++-------- src/lib/resolve-install-credentials.ts | 47 ++++++++++++--------- src/lib/run-with-core.ts | 2 +- 5 files changed, 54 insertions(+), 46 deletions(-) diff --git a/src/lib/installer-core.spec.ts b/src/lib/installer-core.spec.ts index 941c8d82..2062a894 100644 --- a/src/lib/installer-core.spec.ts +++ b/src/lib/installer-core.spec.ts @@ -442,10 +442,12 @@ describe('InstallerCore State Machine', () => { deviceAuthStarted = true; throw new Error('device auth should not be called'); }), - fetchStagingCredentials: fromPromise(async () => ({ - clientId: 'client_unclaimed', - apiKey: 'sk_test_unclaimed', - })), + fetchStagingCredentials: fromPromise( + async () => ({ + clientId: 'client_unclaimed', + apiKey: 'sk_test_unclaimed', + }), + ), }, }); diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index 08e797a0..9ad248a9 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -390,9 +390,11 @@ export const installerMachine = setup({ >(async () => { throw new Error('runDeviceAuth not implemented - provide via machine.provide()'); }), - fetchStagingCredentials: fromPromise(async () => { - throw new Error('fetchStagingCredentials not implemented - provide via machine.provide()'); - }), + fetchStagingCredentials: fromPromise( + async () => { + throw new Error('fetchStagingCredentials not implemented - provide via machine.provide()'); + }, + ), // Branch check actors checkBranch: fromPromise(async () => { throw new Error('checkBranch not implemented - provide via machine.provide()'); @@ -897,7 +899,7 @@ export const installerMachine = setup({ invoke: { id: 'fetchStagingCredentials', src: 'fetchStagingCredentials', - input: ({ context }) => ({ installDir: context.options.installDir }), + input: ({ context }) => ({ installDir: context.options.installDir, envScanConsent: context.envScanConsent }), onDone: { target: '#installer.configuring', actions: [ diff --git a/src/lib/resolve-install-credentials.spec.ts b/src/lib/resolve-install-credentials.spec.ts index b8f6cc27..318c52e2 100644 --- a/src/lib/resolve-install-credentials.spec.ts +++ b/src/lib/resolve-install-credentials.spec.ts @@ -466,39 +466,36 @@ describe('resolveInstallCredentials', () => { }); it('returns the active profile pair when the project carries no key', async () => { - const result = await resolveStagingCredentials(projectDir); + const result = await resolveStagingCredentials(projectDir, true); expect(result).toEqual({ clientId: 'client_01ACTIVE', apiKey: 'sk_test_active_key' }); }); - it('keeps a valid project WORKOS_API_KEY and adopts only the active profile client ID', async () => { + it('refuses a key-only project after a consented scan, routing to the manual prompt', async () => { + // The scan found no valid client ID (or the pair would have gone + // straight to configuring), and no API maps a secret key back to its + // environment — so no fallback pair is safe to write. The machine + // routes staging failures to the manual credential prompt. writeFileSync(join(projectDir, '.env'), 'WORKOS_API_KEY=sk_test_project_key\n'); - const result = await resolveStagingCredentials(projectDir); - - expect(result).toEqual({ clientId: 'client_01ACTIVE', apiKey: 'sk_test_project_key' }); + await expect(resolveStagingCredentials(projectDir, true)).rejects.toThrow(/no valid WORKOS_CLIENT_ID/); + expect(mockFetchStagingCredentials).not.toHaveBeenCalled(); }); - it('keeps the project key on the fresh-fetch path, caching the fetched pair verbatim', async () => { + it('returns the active profile pair for a key-only project when the scan was declined', async () => { + // Declining the scan opts the project out of its env files being used — + // the CLI-side fallback pair applies, overwrite and all. writeFileSync(join(projectDir, '.env'), 'WORKOS_API_KEY=sk_test_project_key\n'); - mockGetActiveEnvironment.mockReturnValue(null); - mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_01STAGING', apiKey: 'sk_test_staging_key' }); - const result = await resolveStagingCredentials(projectDir); + const result = await resolveStagingCredentials(projectDir, false); - expect(result).toEqual({ clientId: 'client_01STAGING', apiKey: 'sk_test_project_key' }); - // The cache stays truthful: it records the staging environment's real - // pair, not the project-key mix returned for this install. - expect(mockSaveStagingCredentials).toHaveBeenCalledWith({ - clientId: 'client_01STAGING', - apiKey: 'sk_test_staging_key', - }); + expect(result).toEqual({ clientId: 'client_01ACTIVE', apiKey: 'sk_test_active_key' }); }); it('treats an invalid project key as absent, matching credential discovery', async () => { writeFileSync(join(projectDir, '.env'), 'WORKOS_API_KEY=not-a-real-key\n'); - const result = await resolveStagingCredentials(projectDir); + const result = await resolveStagingCredentials(projectDir, true); expect(result).toEqual({ clientId: 'client_01ACTIVE', apiKey: 'sk_test_active_key' }); }); diff --git a/src/lib/resolve-install-credentials.ts b/src/lib/resolve-install-credentials.ts index b4e23a53..0d78ca8e 100644 --- a/src/lib/resolve-install-credentials.ts +++ b/src/lib/resolve-install-credentials.ts @@ -69,35 +69,42 @@ async function maybePickInstallEnvironment( * The install machine's staging-credential step. Source priority: active * profile -> cached staging pair -> fresh staging fetch (persisted for reuse). * - * A project that already carries its own valid WORKOS_API_KEY keeps it. The - * preflight no-clobber path is only half the contract: scanning a key-only - * project finds no valid client ID and lands here, so returning the fallback's - * COMPLETE pair would let configureEnvironment upsert a different - * environment's key over the project's. The fallback supplies only the missing - * client ID. An invalid project key is no key -- discovery ignores it too. + * The no-clobber contract for project-owned keys: when the user consented to + * the env-file scan and the project STILL lands here, the scan found no valid + * client ID (a complete pair short-circuits into `configuring`) -- i.e. the + * project is key-only. No API maps a secret key back to its environment, so + * no fallback can supply the matching client ID: adopting another + * environment's would configure the app against two environments at once, and + * returning a full fallback pair would silently re-point it. Refuse both by + * throwing -- the machine routes staging failures to the manual prompt, where + * the user supplies the matching pair. When the scan was declined the project + * opted out of its env files being used, and the fallback pair applies. */ -export async function resolveStagingCredentials(installDir: string): Promise<{ clientId: string; apiKey: string }> { +export async function resolveStagingCredentials( + installDir: string, + envScanConsent: boolean | undefined, +): Promise<{ clientId: string; apiKey: string }> { const { getActiveEnvironment, getConfig, saveConfig } = await import('./config-store.js'); const { getAccessToken, getStagingCredentials, saveStagingCredentials } = await import('./credentials.js'); - const { readProjectEnvCredentials } = await import('./project-env.js'); - const { isValidApiKey } = await import('./credential-discovery.js'); - const { logInfo } = await import('../utils/debug.js'); - const projectKey = readProjectEnvCredentials(installDir).apiKey; - const keepKey = projectKey && isValidApiKey(projectKey) ? projectKey : undefined; - const forProject = ({ clientId, apiKey }: { clientId: string; apiKey: string }) => { - if (!keepKey) return { clientId, apiKey }; - logInfo('[resolve-install-credentials] Project WORKOS_API_KEY kept -- adopting only the client ID'); - return { clientId, apiKey: keepKey }; - }; + if (envScanConsent) { + const { readProjectEnvCredentials } = await import('./project-env.js'); + const { isValidApiKey } = await import('./credential-discovery.js'); + const projectKey = readProjectEnvCredentials(installDir).apiKey; + if (projectKey && isValidApiKey(projectKey)) { + throw new Error( + 'This project already has WORKOS_API_KEY but no valid WORKOS_CLIENT_ID, and the matching client ID cannot be looked up automatically', + ); + } + } const activeEnv = getActiveEnvironment(); if (activeEnv?.clientId && activeEnv?.apiKey) { - return forProject({ clientId: activeEnv.clientId, apiKey: activeEnv.apiKey }); + return { clientId: activeEnv.clientId, apiKey: activeEnv.apiKey }; } const cached = getStagingCredentials(); - if (cached) return forProject(cached); + if (cached) return cached; const token = getAccessToken(); if (!token) throw new Error('No access token available'); @@ -124,7 +131,7 @@ export async function resolveStagingCredentials(installDir: string): Promise<{ c // Don't block install if config-store write fails } - return forProject(staging); + return staging; } export async function resolveInstallCredentials( diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index bafafbac..63229ba4 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -440,7 +440,7 @@ export async function runWithCore(options: InstallerOptions): Promise { fetchStagingCredentials: fromPromise(async ({ input }) => { const { resolveStagingCredentials } = await import('./resolve-install-credentials.js'); - return resolveStagingCredentials(input.installDir); + return resolveStagingCredentials(input.installDir, input.envScanConsent); }), // Branch check actors From 65123eb61c2ebfb9a8d5b793a9c709cf9c320deb Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 26 Aug 2026 14:11:19 -0500 Subject: [PATCH 5/5] chore: formatting --- src/lib/installer-core.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index 9ad248a9..3468e385 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -899,7 +899,10 @@ export const installerMachine = setup({ invoke: { id: 'fetchStagingCredentials', src: 'fetchStagingCredentials', - input: ({ context }) => ({ installDir: context.options.installDir, envScanConsent: context.envScanConsent }), + input: ({ context }) => ({ + installDir: context.options.installDir, + envScanConsent: context.envScanConsent, + }), onDone: { target: '#installer.configuring', actions: [