From f476aa5c823bc5661fe1dc138cdf5007f9e8deb4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 26 Aug 2026 09:46:33 -0500 Subject: [PATCH 1/6] feat(install): name the environment in installer status lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Using active environment' followed by 'Using your active WorkOS environment' answered none of the question that started this thread: WHICH environment? Now that profiles store project-prefixed dashboard names, the installer says so: ✓ Environment ready ✓ Using environment: cli-branding-smoke > Staging (staging-3) Falls back to the old copy when the profile has never resolved a name (e.g. the just-provisioned device-auth path). The profile-label ternary had reached three copies (profile list, install picker, adapter), so it is now a shared config-store helper: profileEnvironmentLabel(). --- src/commands/env.ts | 24 +++++++---------- src/lib/adapters/cli-adapter.spec.ts | 29 +++++++++++++++++++++ src/lib/adapters/cli-adapter.ts | 15 ++++++++--- src/lib/config-store.ts | 15 +++++++++++ src/lib/resolve-install-credentials.spec.ts | 18 ++++++++----- src/lib/resolve-install-credentials.ts | 10 +++---- 6 files changed, 80 insertions(+), 31 deletions(-) diff --git a/src/commands/env.ts b/src/commands/env.ts index d0d1a67e..994f7938 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -1,7 +1,13 @@ import chalk from 'chalk'; import ui from '../utils/ui.js'; -import { getConfig, saveConfig, isUnclaimedEnvironment, freshEnvKey } from '../lib/config-store.js'; -import type { CliConfig, EnvironmentConfig } from '../lib/config-store.js'; +import { + getConfig, + saveConfig, + isUnclaimedEnvironment, + freshEnvKey, + profileEnvironmentLabel, +} from '../lib/config-store.js'; +import type { CliConfig } from '../lib/config-store.js'; import { getApiBaseUrlSource } from '../lib/api-key.js'; import { outputSuccess, outputJson, exitWithError, isJsonMode } from '../utils/output.js'; import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; @@ -17,16 +23,6 @@ import { tryResolveProfileEnvironmentId } from '../lib/environment-target.js'; const ENV_NAME_REGEX = /^[a-z0-9\-_]+$/; -/** - * "Project > Environment" display label for a profile's resolved dashboard - * environment (names are only unique per project), or undefined when the - * profile has no resolved name. - */ -function profileEnvironmentLabel(env: EnvironmentConfig): string | undefined { - if (!env.environmentName) return undefined; - return env.projectName ? `${env.projectName} > ${env.environmentName}` : env.environmentName; -} - function validateEnvName(name: string | undefined): string | undefined { if (!name || !ENV_NAME_REGEX.test(name)) { return 'Name must contain only lowercase letters, numbers, hyphens, and underscores'; @@ -276,7 +272,7 @@ export async function runEnvSwitch(name?: string): Promise { if (env.type === 'sandbox') label += ` [Sandbox]`; if (env.endpoint) label += ` [${env.endpoint}]`; if (key === config.activeEnvironment) label += chalk.green(' (active)'); - const environment = profileEnvironmentLabel(env) ?? env.environmentId; + const environment = profileEnvironmentLabel(env); return { value: key, label, ...(environment && { hint: environment }) }; }); @@ -380,7 +376,7 @@ export async function runEnvList(): Promise { const endpoint = env.endpoint ? endpointRaw : chalk.dim(endpointRaw); // Name-first: the dashboard name is what users recognize; fall back to // the raw ID for profiles resolved before names were stored. - const environment = profileEnvironmentLabel(env) ?? env.environmentId ?? chalk.dim('—'); + const environment = profileEnvironmentLabel(env) ?? chalk.dim('—'); console.log([marker, name, type.padEnd(typeW), endpoint, environment].join(' ')); } diff --git a/src/lib/adapters/cli-adapter.spec.ts b/src/lib/adapters/cli-adapter.spec.ts index e76d6da2..bf2b7518 100644 --- a/src/lib/adapters/cli-adapter.spec.ts +++ b/src/lib/adapters/cli-adapter.spec.ts @@ -31,6 +31,17 @@ vi.mock('../../utils/ui.js', () => ({ }, })); +// The adapter names the environment when the active profile has resolved one; +// default to no active profile so the fallback copy stays under test. +const mockGetActiveEnvironment = vi.fn(); +vi.mock('../config-store.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getActiveEnvironment: (...args: unknown[]) => mockGetActiveEnvironment(...args), + }; +}); + vi.mock('../settings.js', () => ({ getConfig: vi.fn(() => ({ branding: { @@ -444,5 +455,23 @@ describe('CLIAdapter', () => { expect(calls).toContain('Using your active WorkOS environment'); expect(calls.join('\n')).not.toMatch(/retrieved/i); }); + + it('stored path names the environment when the profile has resolved one', async () => { + mockGetActiveEnvironment.mockReturnValue({ + name: 'staging-3', + type: 'sandbox', + apiKey: 'sk_test_x', + environmentName: 'Staging', + projectName: 'cli-branding-smoke', + }); + await adapter.start(); + const ui = await import('../../utils/ui.js'); + + emitter.emit('staging:fetching', {}); + emitter.emit('staging:success', { source: 'stored' }); + + const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0])); + expect(calls).toContain('Using environment: cli-branding-smoke > Staging (staging-3)'); + }); }); }); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 65043459..f54dcf2c 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -4,6 +4,7 @@ import { relative } from 'node:path'; import ui, { PromptUnavailableError } from '../../utils/ui.js'; import chalk from 'chalk'; import { getConfig } from '../settings.js'; +import { getActiveEnvironment, profileEnvironmentLabel } from '../config-store.js'; import { ProgressTracker } from '../progress-tracker.js'; import { renderCompletionSummary, renderBrandMark } from '../../utils/summary-box.js'; import { classifyAgentFailure, describeAgentFailure } from '../failure-classifier.js'; @@ -330,15 +331,21 @@ export class CLIAdapter implements InstallerAdapter { }; private handleStagingSuccess = ({ source }: InstallerEvents['staging:success']): void => { + // Name the environment when the profile has resolved one — "Using your + // active WorkOS environment" answers none of "which one?" (the question + // that started this: profiles like 'staging-3' are opaque labels). + const active = getActiveEnvironment(); + const label = active ? profileEnvironmentLabel(active) : undefined; + const named = label ? `${label} (${active!.name})` : undefined; if (source === 'device') { this.stopSpinner('Environment ready'); - ui.log.success('Set up a WorkOS environment for this install'); + ui.log.success(named ? `Set up environment: ${named}` : 'Set up a WorkOS environment for this install'); } else if (source === 'stored') { - this.stopSpinner('Using active environment'); - ui.log.success('Using your active WorkOS environment'); + this.stopSpinner('Environment ready'); + ui.log.success(named ? `Using environment: ${named}` : 'Using your active WorkOS environment'); } else { this.stopSpinner('Environment ready'); - ui.log.success('Using your WorkOS environment'); + ui.log.success(named ? `Using environment: ${named}` : 'Using your WorkOS environment'); } }; diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index 9705dbb9..75d6b3c3 100644 --- a/src/lib/config-store.ts +++ b/src/lib/config-store.ts @@ -132,6 +132,21 @@ export function setProfileEnvironmentId( saveConfig(config); } +/** + * Human-readable label for the dashboard environment a profile targets: + * "Project > Environment" when both are known (environment names are only + * unique per project), the bare name, the raw ID, or undefined when the + * profile has never resolved. Shared by every surface that prints a + * profile's environment (profile list/switch, install picker, installer + * status lines). + */ +export function profileEnvironmentLabel(env: EnvironmentConfig): string | undefined { + if (env.environmentName) { + return env.projectName ? `${env.projectName} > ${env.environmentName}` : env.environmentName; + } + return env.environmentId; +} + /** Pick a non-colliding environments key: `base`, else `base-2`, `base-3`, … */ export function freshEnvKey(config: CliConfig, base: string): string { if (!config.environments[base]) return base; diff --git a/src/lib/resolve-install-credentials.spec.ts b/src/lib/resolve-install-credentials.spec.ts index 318c52e2..a20a2fd1 100644 --- a/src/lib/resolve-install-credentials.spec.ts +++ b/src/lib/resolve-install-credentials.spec.ts @@ -9,13 +9,17 @@ 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), -})); +vi.mock('./config-store.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + 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(); diff --git a/src/lib/resolve-install-credentials.ts b/src/lib/resolve-install-credentials.ts index 0d78ca8e..d6ef4231 100644 --- a/src/lib/resolve-install-credentials.ts +++ b/src/lib/resolve-install-credentials.ts @@ -36,7 +36,9 @@ async function maybePickInstallEnvironment( const { readProjectEnvCredentials } = await import('./project-env.js'); if (readProjectEnvCredentials(installDir).apiKey) return activeEnv; - const { getConfig, getActiveEnvironment, setActiveEnvironment } = await import('./config-store.js'); + const { getConfig, getActiveEnvironment, setActiveEnvironment, profileEnvironmentLabel } = await import( + './config-store.js' + ); const config = getConfig(); if (!config) return activeEnv; const candidates = Object.entries(config.environments).filter(([, env]) => env.apiKey); @@ -48,11 +50,7 @@ async function maybePickInstallEnvironment( 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; + const dashboardName = profileEnvironmentLabel(env); let label = dashboardName ? `${key} — ${dashboardName}` : key; if (key === config.activeEnvironment) label += ' (active)'; const hint = env.type === 'sandbox' ? 'Sandbox' : env.type === 'unclaimed' ? 'Unclaimed' : 'Production'; From 5ae1a598a244563481fa2780af3d915dd8feabcd Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 26 Aug 2026 09:54:27 -0500 Subject: [PATCH 2/6] feat(install): give the environment picker room to breathe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: the picker rows were scrunched — profile key, em-dash, project, environment, parenthetical, and type all crammed into one run-on label. - environment-first: the dashboard label leads; the profile key demotes to dim metadata (only shown when it differs from the display name) - column-aligned: names pad to a shared width (on the plain string, before color — ANSI never skews the columns), metadata reads as a column - active row gets a green ● instead of a parenthetical - a framed dim note above the prompt gives it air: 'This machine knows N WorkOS environments — pick the one this app should call home.' Still clig: human-mode only (JSON/CI paths unchanged), color used only to de-emphasize metadata and mark the active row. --- src/lib/resolve-install-credentials.spec.ts | 12 +++++++---- src/lib/resolve-install-credentials.ts | 24 ++++++++++++++++----- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/lib/resolve-install-credentials.spec.ts b/src/lib/resolve-install-credentials.spec.ts index a20a2fd1..5aa7e500 100644 --- a/src/lib/resolve-install-credentials.spec.ts +++ b/src/lib/resolve-install-credentials.spec.ts @@ -48,6 +48,7 @@ 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() }, + note: vi.fn(), select: (...args: unknown[]) => mockSelect(...args), isCancel: (value: unknown) => value === CANCEL, }; @@ -375,10 +376,13 @@ describe('resolveInstallCredentials', () => { 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)', - ]); + // Environment-first labels, column-aligned, with the profile key and + // type dim in the metadata and the active row marked. + const labels = call.options.map((o) => o.label); + expect(labels[0]).toMatch(/^Nick's Team's Project > test12\s+staging · Sandbox$/); + expect(labels[1]).toMatch(/^cli-branding-smoke > Staging\s+staging-3 · Sandbox ● active$/); + // Framed intro line gives the prompt breathing room. + expect(mockUi.note).toHaveBeenCalledWith(expect.stringContaining('pick the one this app should call home')); expect(mockSetActiveEnvironment).toHaveBeenCalledWith('staging'); }); diff --git a/src/lib/resolve-install-credentials.ts b/src/lib/resolve-install-credentials.ts index d6ef4231..01800fb9 100644 --- a/src/lib/resolve-install-credentials.ts +++ b/src/lib/resolve-install-credentials.ts @@ -46,15 +46,29 @@ async function maybePickInstallEnvironment( const ui = (await import('../utils/ui.js')).default; const { ExitCode, exitWithCode } = await import('../utils/exit-codes.js'); + const chalk = (await import('chalk')).default; + + // Lead with the environment (the thing being chosen); the profile key is + // bookkeeping and rides dim in the metadata. Labels are column-aligned — + // padEnd runs on the PLAIN name before any color, so ANSI codes never + // skew the columns (see the env-list alignment bug). + const displayFor = (key: string, env: EnvironmentConfig): string => profileEnvironmentLabel(env) ?? key; + const nameW = Math.max(...candidates.map(([key, env]) => displayFor(key, env).length)); + + ui.note( + `This machine knows ${candidates.length} WorkOS environments — pick the one this app should call home.`, + ); const choice = await ui.select({ message: 'Which WorkOS environment should this install use?', options: candidates.map(([key, env]) => { - const dashboardName = profileEnvironmentLabel(env); - 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 }; + const display = displayFor(key, env); + const type = env.type === 'sandbox' ? 'Sandbox' : env.type === 'unclaimed' ? 'Unclaimed' : 'Production'; + // Only show the profile key when it isn't already the display name. + const meta = [display === key ? null : key, type].filter(Boolean).join(' · '); + let label = `${display.padEnd(nameW)} ${chalk.dim(meta)}`; + if (key === config.activeEnvironment) label += ` ${chalk.green('● active')}`; + return { value: key, label }; }), initialValue: config.activeEnvironment, }); From 58e650fa26d0e9f73bac0a0aab8626f6282e1b78 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 26 Aug 2026 10:43:49 -0500 Subject: [PATCH 3/6] fix(install): only name environments the active profile actually supplied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review P1: the staging:success handler named the active profile unconditionally, but the credentials actor (run-with-core.ts) only uses the profile when it has BOTH apiKey and clientId — otherwise it falls through to cached/fetched staging credentials, and the named line would report a different environment than the one being configured. Gate the naming on that same condition and fall back to the generic copy. Also: oxfmt formatting on resolve-install-credentials.ts (CI Lint). Addresses PR #231 review. --- src/lib/adapters/cli-adapter.spec.ts | 25 ++++++++++++++++++++++++- src/lib/adapters/cli-adapter.ts | 9 ++++++++- src/lib/resolve-install-credentials.ts | 9 +++------ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/lib/adapters/cli-adapter.spec.ts b/src/lib/adapters/cli-adapter.spec.ts index bf2b7518..5c276c07 100644 --- a/src/lib/adapters/cli-adapter.spec.ts +++ b/src/lib/adapters/cli-adapter.spec.ts @@ -456,11 +456,12 @@ describe('CLIAdapter', () => { expect(calls.join('\n')).not.toMatch(/retrieved/i); }); - it('stored path names the environment when the profile has resolved one', async () => { + it('stored path names the environment when the profile supplied the credentials', async () => { mockGetActiveEnvironment.mockReturnValue({ name: 'staging-3', type: 'sandbox', apiKey: 'sk_test_x', + clientId: 'client_x', environmentName: 'Staging', projectName: 'cli-branding-smoke', }); @@ -473,5 +474,27 @@ describe('CLIAdapter', () => { const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0])); expect(calls).toContain('Using environment: cli-branding-smoke > Staging (staging-3)'); }); + + it('stored path never names a profile that could not have supplied the credentials', async () => { + // No clientId: the credentials actor falls through to cached/fetched + // staging credentials, so naming this profile would label the wrong + // environment (see run-with-core.ts fetchStagingCredentials). + mockGetActiveEnvironment.mockReturnValue({ + name: 'staging-3', + type: 'sandbox', + apiKey: 'sk_test_x', + environmentName: 'Staging', + projectName: 'cli-branding-smoke', + }); + await adapter.start(); + const ui = await import('../../utils/ui.js'); + + emitter.emit('staging:fetching', {}); + emitter.emit('staging:success', { source: 'stored' }); + + const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0])); + expect(calls).toContain('Using your active WorkOS environment'); + expect(calls.join('\n')).not.toContain('Using environment:'); + }); }); }); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index f54dcf2c..7cf188a9 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -334,8 +334,15 @@ export class CLIAdapter implements InstallerAdapter { // Name the environment when the profile has resolved one — "Using your // active WorkOS environment" answers none of "which one?" (the question // that started this: profiles like 'staging-3' are opaque labels). + // + // Only when the active profile actually supplied the credentials: the + // fetchStagingCredentials actor (run-with-core.ts) uses the profile only + // when it has BOTH apiKey and clientId, otherwise it falls through to + // cached/fetched staging credentials — naming the profile then would + // report a different environment than the one being configured. const active = getActiveEnvironment(); - const label = active ? profileEnvironmentLabel(active) : undefined; + const suppliedCredentials = Boolean(active?.apiKey && active?.clientId); + const label = active && suppliedCredentials ? profileEnvironmentLabel(active) : undefined; const named = label ? `${label} (${active!.name})` : undefined; if (source === 'device') { this.stopSpinner('Environment ready'); diff --git a/src/lib/resolve-install-credentials.ts b/src/lib/resolve-install-credentials.ts index 01800fb9..a1433d6a 100644 --- a/src/lib/resolve-install-credentials.ts +++ b/src/lib/resolve-install-credentials.ts @@ -36,9 +36,8 @@ async function maybePickInstallEnvironment( const { readProjectEnvCredentials } = await import('./project-env.js'); if (readProjectEnvCredentials(installDir).apiKey) return activeEnv; - const { getConfig, getActiveEnvironment, setActiveEnvironment, profileEnvironmentLabel } = await import( - './config-store.js' - ); + const { getConfig, getActiveEnvironment, setActiveEnvironment, profileEnvironmentLabel } = + await import('./config-store.js'); const config = getConfig(); if (!config) return activeEnv; const candidates = Object.entries(config.environments).filter(([, env]) => env.apiKey); @@ -55,9 +54,7 @@ async function maybePickInstallEnvironment( const displayFor = (key: string, env: EnvironmentConfig): string => profileEnvironmentLabel(env) ?? key; const nameW = Math.max(...candidates.map(([key, env]) => displayFor(key, env).length)); - ui.note( - `This machine knows ${candidates.length} WorkOS environments — pick the one this app should call home.`, - ); + ui.note(`This machine knows ${candidates.length} WorkOS environments — pick the one this app should call home.`); const choice = await ui.select({ message: 'Which WorkOS environment should this install use?', From 92f12674b8fce685a26493557f6a3672d1c29ef2 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 26 Aug 2026 13:30:44 -0500 Subject: [PATCH 4/6] fix(install): name the active profile only when it supplied the exact credentials Field presence is not enough: resolveStagingCredentials keeps a project-owned WORKOS_API_KEY and pairs it with the active profile's client ID, so the profile can be complete while the app's API calls target the project's environment. staging:success now carries the credentials in use and the adapter names the profile only on an exact match; anything else gets the generic copy. A wrong name is worse than none. Addresses PR #231 review. --- src/lib/adapters/cli-adapter.spec.ts | 26 +++++++++++++++++++++++++- src/lib/adapters/cli-adapter.ts | 21 ++++++++++++++------- src/lib/events.ts | 2 +- src/lib/installer-core.ts | 5 ++++- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/lib/adapters/cli-adapter.spec.ts b/src/lib/adapters/cli-adapter.spec.ts index 5c276c07..65c2e2f6 100644 --- a/src/lib/adapters/cli-adapter.spec.ts +++ b/src/lib/adapters/cli-adapter.spec.ts @@ -469,12 +469,36 @@ describe('CLIAdapter', () => { const ui = await import('../../utils/ui.js'); emitter.emit('staging:fetching', {}); - emitter.emit('staging:success', { source: 'stored' }); + emitter.emit('staging:success', { source: 'stored', credentials: { clientId: 'client_x', apiKey: 'sk_test_x' } }); const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0])); expect(calls).toContain('Using environment: cli-branding-smoke > Staging (staging-3)'); }); + it('stored path never names the profile for mixed project-key credentials', async () => { + // resolveStagingCredentials keeps a project-owned WORKOS_API_KEY and + // pairs it with the active profile's client ID — the profile is + // complete, but the app's API calls target the project's environment, + // so naming the profile would label the wrong one. + mockGetActiveEnvironment.mockReturnValue({ + name: 'staging-3', + type: 'sandbox', + apiKey: 'sk_test_x', + clientId: 'client_x', + environmentName: 'Staging', + projectName: 'cli-branding-smoke', + }); + await adapter.start(); + const ui = await import('../../utils/ui.js'); + + emitter.emit('staging:fetching', {}); + emitter.emit('staging:success', { source: 'stored', credentials: { clientId: 'client_x', apiKey: 'sk_test_project' } }); + + const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0])); + expect(calls).toContain('Using your active WorkOS environment'); + expect(calls.join('\n')).not.toContain('Using environment:'); + }); + it('stored path never names a profile that could not have supplied the credentials', async () => { // No clientId: the credentials actor falls through to cached/fetched // staging credentials, so naming this profile would label the wrong diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 7cf188a9..34bcab1f 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -330,18 +330,25 @@ export class CLIAdapter implements InstallerAdapter { this.spinner.start('Fetching your WorkOS credentials...'); }; - private handleStagingSuccess = ({ source }: InstallerEvents['staging:success']): void => { + private handleStagingSuccess = ({ source, credentials }: InstallerEvents['staging:success']): void => { // Name the environment when the profile has resolved one — "Using your // active WorkOS environment" answers none of "which one?" (the question // that started this: profiles like 'staging-3' are opaque labels). // - // Only when the active profile actually supplied the credentials: the - // fetchStagingCredentials actor (run-with-core.ts) uses the profile only - // when it has BOTH apiKey and clientId, otherwise it falls through to - // cached/fetched staging credentials — naming the profile then would - // report a different environment than the one being configured. + // Only when the active profile actually supplied the credentials in use. + // Field presence is not enough: resolveStagingCredentials keeps a + // project-owned WORKOS_API_KEY and pairs it with the profile's client ID, + // so the profile can be complete while the app's API calls still target + // the PROJECT's environment. Exact match or generic copy — a wrong name + // is worse than none. const active = getActiveEnvironment(); - const suppliedCredentials = Boolean(active?.apiKey && active?.clientId); + const suppliedCredentials = Boolean( + active?.apiKey && + active?.clientId && + credentials && + active.apiKey === credentials.apiKey && + active.clientId === credentials.clientId, + ); const label = active && suppliedCredentials ? profileEnvironmentLabel(active) : undefined; const named = label ? `${label} (${active!.name})` : undefined; if (source === 'device') { diff --git a/src/lib/events.ts b/src/lib/events.ts index bb28d476..e0f9e961 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -72,7 +72,7 @@ export interface InstallerEvents { 'device:error': { message: string }; // Staging API events 'staging:fetching': Record; - 'staging:success': { source?: 'device' | 'stored' }; + 'staging:success': { source?: 'device' | 'stored'; credentials?: { clientId: string; apiKey?: string } }; 'staging:error': { message: string; statusCode?: number }; 'config:start': Record; 'config:complete': Record; diff --git a/src/lib/installer-core.ts b/src/lib/installer-core.ts index 3468e385..dcbc411f 100644 --- a/src/lib/installer-core.ts +++ b/src/lib/installer-core.ts @@ -167,7 +167,10 @@ export const installerMachine = setup({ context.emitter.emit('staging:fetching', {}); }, emitStagingSuccess: ({ context }) => { - context.emitter.emit('staging:success', { source: context.deviceAuth ? 'device' : 'stored' }); + context.emitter.emit('staging:success', { + source: context.deviceAuth ? 'device' : 'stored', + credentials: context.credentials, + }); }, emitStagingError: ({ context }) => { const message = context.error?.message ?? 'Failed to fetch staging credentials'; From f18c068f39c06e29841dff6107f1d8077b162202 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 26 Aug 2026 13:41:07 -0500 Subject: [PATCH 5/6] chore(install): reword the staging-name gate comment for the refusal path --- src/lib/adapters/cli-adapter.spec.ts | 8 ++++---- src/lib/adapters/cli-adapter.ts | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/lib/adapters/cli-adapter.spec.ts b/src/lib/adapters/cli-adapter.spec.ts index 65c2e2f6..90ae2b55 100644 --- a/src/lib/adapters/cli-adapter.spec.ts +++ b/src/lib/adapters/cli-adapter.spec.ts @@ -476,10 +476,10 @@ describe('CLIAdapter', () => { }); it('stored path never names the profile for mixed project-key credentials', async () => { - // resolveStagingCredentials keeps a project-owned WORKOS_API_KEY and - // pairs it with the active profile's client ID — the profile is - // complete, but the app's API calls target the project's environment, - // so naming the profile would label the wrong one. + // Defense in depth: if the credentials in use ever combine another + // source's client ID with a different API key (a project-kept key was + // the case that motivated this gate), the profile must not be named — + // the app's API calls target the key's environment, not the profile's. mockGetActiveEnvironment.mockReturnValue({ name: 'staging-3', type: 'sandbox', diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 34bcab1f..b19c8f4e 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -336,11 +336,10 @@ export class CLIAdapter implements InstallerAdapter { // that started this: profiles like 'staging-3' are opaque labels). // // Only when the active profile actually supplied the credentials in use. - // Field presence is not enough: resolveStagingCredentials keeps a - // project-owned WORKOS_API_KEY and pairs it with the profile's client ID, - // so the profile can be complete while the app's API calls still target - // the PROJECT's environment. Exact match or generic copy — a wrong name - // is worse than none. + // Field presence is not enough: the credentials can come from a cached or + // freshly fetched staging pair while a complete profile sits active, and + // key-only projects are refused out of staging resolution entirely. + // Exact match or generic copy — a wrong name is worse than none. const active = getActiveEnvironment(); const suppliedCredentials = Boolean( active?.apiKey && From d108e0518b9c05d57593e06d3df5c7ade37fe434 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 26 Aug 2026 14:11:36 -0500 Subject: [PATCH 6/6] chore: formatting --- src/lib/adapters/cli-adapter.spec.ts | 5 ++++- src/lib/adapters/cli-adapter.ts | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/lib/adapters/cli-adapter.spec.ts b/src/lib/adapters/cli-adapter.spec.ts index 90ae2b55..b0906a1d 100644 --- a/src/lib/adapters/cli-adapter.spec.ts +++ b/src/lib/adapters/cli-adapter.spec.ts @@ -492,7 +492,10 @@ describe('CLIAdapter', () => { const ui = await import('../../utils/ui.js'); emitter.emit('staging:fetching', {}); - emitter.emit('staging:success', { source: 'stored', credentials: { clientId: 'client_x', apiKey: 'sk_test_project' } }); + emitter.emit('staging:success', { + source: 'stored', + credentials: { clientId: 'client_x', apiKey: 'sk_test_project' }, + }); const calls = vi.mocked(ui.default.log.success).mock.calls.map((c) => String(c[0])); expect(calls).toContain('Using your active WorkOS environment'); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index b19c8f4e..7f50a671 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -343,10 +343,10 @@ export class CLIAdapter implements InstallerAdapter { const active = getActiveEnvironment(); const suppliedCredentials = Boolean( active?.apiKey && - active?.clientId && - credentials && - active.apiKey === credentials.apiKey && - active.clientId === credentials.clientId, + active?.clientId && + credentials && + active.apiKey === credentials.apiKey && + active.clientId === credentials.clientId, ); const label = active && suppliedCredentials ? profileEnvironmentLabel(active) : undefined; const named = label ? `${label} (${active!.name})` : undefined;