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..b0906a1d 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,73 @@ 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 supplied the credentials', async () => { + 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_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 () => { + // 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', + 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 + // 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 65043459..7f50a671 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'; @@ -329,16 +330,35 @@ 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 in use. + // 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 && + 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') { 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/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'; diff --git a/src/lib/resolve-install-credentials.spec.ts b/src/lib/resolve-install-credentials.spec.ts index 318c52e2..5aa7e500 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(); @@ -44,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, }; @@ -371,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 0d78ca8e..a1433d6a 100644 --- a/src/lib/resolve-install-credentials.ts +++ b/src/lib/resolve-install-credentials.ts @@ -36,7 +36,8 @@ 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); @@ -44,19 +45,27 @@ 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 = 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 }; + 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, });