From 232b00633e461e28ecddd83639cd0027de80f99b Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 25 Aug 2026 18:48:33 -0500 Subject: [PATCH 1/3] feat: prefix the project name on environment display labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teams can hold multiple projects, and environment names are only unique per project — two projects can each have a 'Staging'. Bare names are ambiguous exactly where profiles/pickers try to disambiguate. - store projectName on profiles alongside environmentName, captured and healed by every resolution path (login join, picker, healing, use) - display 'Project > Environment' in profile list, the profile switch hint, the environment picker, and the environment use confirmation - profile list --json gains projectName per profile; environment use --json gains projectName in its payload - shared formatEnvironmentLabel() for TeamEnvironment-shaped call sites --- src/commands/env.spec.ts | 8 ++++++-- src/commands/env.ts | 17 ++++++++++++++--- src/commands/environment.spec.ts | 6 +++--- src/commands/environment.ts | 6 ++++-- src/lib/config-store.ts | 18 ++++++++++++++++-- src/lib/environment-target.spec.ts | 17 ++++++++++++++++- src/lib/environment-target.ts | 28 +++++++++++++++++++++------- 7 files changed, 80 insertions(+), 20 deletions(-) diff --git a/src/commands/env.spec.ts b/src/commands/env.spec.ts index c835dd66..7a7f0187 100644 --- a/src/commands/env.spec.ts +++ b/src/commands/env.spec.ts @@ -343,13 +343,15 @@ describe('env commands', () => { const config = getConfig()!; config.environments.prod.environmentId = 'environment_123'; config.environments.prod.environmentName = 'Production'; + config.environments.prod.projectName = 'My Project'; config.environments.legacy.environmentId = 'environment_456'; saveConfig(config); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); await runEnvList(); const lines = logSpy.mock.calls.map((c) => c.map(String).join(' ')); expect(lines.some((l) => l.includes('Environment'))).toBe(true); - expect(lines.some((l) => l.includes('prod') && l.includes('Production'))).toBe(true); + // Project-prefixed: environment names are only unique per project. + expect(lines.some((l) => l.includes('prod') && l.includes('My Project > Production'))).toBe(true); expect(lines.some((l) => l.includes('legacy') && l.includes('environment_456'))).toBe(true); logSpy.mockRestore(); }); @@ -592,17 +594,19 @@ describe('env commands', () => { expect(output.data[0].environmentName).toBeNull(); }); - it('runEnvList includes the stored environmentId and environmentName per profile', async () => { + it('runEnvList includes the stored environmentId, environmentName, and projectName per profile', async () => { await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); const config = getConfig()!; config.environments.prod.environmentId = 'environment_123'; config.environments.prod.environmentName = 'Production'; + config.environments.prod.projectName = 'My Project'; saveConfig(config); consoleOutput = []; await runEnvList(); const output = JSON.parse(consoleOutput[0]); expect(output.data[0].environmentId).toBe('environment_123'); expect(output.data[0].environmentName).toBe('Production'); + expect(output.data[0].projectName).toBe('My Project'); }); it('runEnvList outputs empty data array when no environments', async () => { diff --git a/src/commands/env.ts b/src/commands/env.ts index bfc40df4..d0d1a67e 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import ui from '../utils/ui.js'; import { getConfig, saveConfig, isUnclaimedEnvironment, freshEnvKey } from '../lib/config-store.js'; -import type { CliConfig } from '../lib/config-store.js'; +import type { CliConfig, EnvironmentConfig } 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,6 +17,16 @@ 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'; @@ -266,7 +276,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 = env.environmentName ?? env.environmentId; + const environment = profileEnvironmentLabel(env) ?? env.environmentId; return { value: key, label, ...(environment && { hint: environment }) }; }); @@ -329,6 +339,7 @@ export async function runEnvList(): Promise { hasClientId: !!env.clientId, environmentId: env.environmentId ?? null, environmentName: env.environmentName ?? null, + projectName: env.projectName ?? null, })); outputJson({ data, override }); return; @@ -369,7 +380,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 = env.environmentName ?? env.environmentId ?? chalk.dim('—'); + const environment = profileEnvironmentLabel(env) ?? env.environmentId ?? chalk.dim('—'); console.log([marker, name, type.padEnd(typeW), endpoint, environment].join(' ')); } diff --git a/src/commands/environment.spec.ts b/src/commands/environment.spec.ts index 4ce3c9e6..2886a9a8 100644 --- a/src/commands/environment.spec.ts +++ b/src/commands/environment.spec.ts @@ -214,7 +214,7 @@ describe('environment command', () => { }, }); await runEnvironmentList(); - expect(mockSetProfileEnvironmentId).toHaveBeenCalledWith('staging', 'env_2', 'Prod'); + expect(mockSetProfileEnvironmentId).toHaveBeenCalledWith('staging', 'env_2', 'Prod', 'P1'); }); it('outputs JSON in json mode', async () => { @@ -238,7 +238,7 @@ describe('environment command', () => { it('persists an explicit environment ID onto the active profile', async () => { mockGraphqlRequest.mockResolvedValue(TEAM_DATA); await runEnvironmentUse('env_2'); - expect(mockSetProfileEnvironmentId).toHaveBeenCalledWith('staging', 'env_2', 'Prod'); + expect(mockSetProfileEnvironmentId).toHaveBeenCalledWith('staging', 'env_2', 'Prod', 'P1'); expect(consoleOutput.join('\n')).toContain('Prod'); }); @@ -255,7 +255,7 @@ describe('environment command', () => { mockGraphqlRequest.mockResolvedValue(TEAM_DATA); mockPromptForEnvironment.mockResolvedValue('env_2'); await runEnvironmentUse(); - expect(mockSetProfileEnvironmentId).toHaveBeenCalledWith('staging', 'env_2', 'Prod'); + expect(mockSetProfileEnvironmentId).toHaveBeenCalledWith('staging', 'env_2', 'Prod', 'P1'); }); it('exits when there is no active profile', async () => { diff --git a/src/commands/environment.ts b/src/commands/environment.ts index 1c69caae..cd9115cd 100644 --- a/src/commands/environment.ts +++ b/src/commands/environment.ts @@ -18,6 +18,7 @@ import { runEnvScopedOperation, runTeamScopedOperation } from '../lib/dashboard- import { requireCommandToken } from '../lib/command-auth.js'; import { fetchTeamEnvironments, + formatEnvironmentLabel, healProfiles, promptForEnvironment, type TeamEnvironment, @@ -142,12 +143,13 @@ export async function runEnvironmentUse(environmentIdArg?: string): Promise env.id === targetId); - setProfileEnvironmentId(activeName, chosen!.id, chosen!.name); + setProfileEnvironmentId(activeName, chosen!.id, chosen!.name, chosen!.projectName); - outputSuccess(`Active profile ${chalk.bold(activeName)} now targets ${chalk.bold(chosen!.name ?? chosen!.id)}`, { + outputSuccess(`Active profile ${chalk.bold(activeName)} now targets ${chalk.bold(formatEnvironmentLabel(chosen!))}`, { profile: activeName, environmentId: chosen!.id, environmentName: chosen!.name ?? null, + projectName: chosen!.projectName ?? null, }); } diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index 43d370ec..9705dbb9 100644 --- a/src/lib/config-store.ts +++ b/src/lib/config-store.ts @@ -31,6 +31,12 @@ interface BaseEnvironmentConfig { * Refreshed by the same resolution paths that maintain `environmentId`. */ environmentName?: string; + /** + * The owning project's name, captured with `environmentName`. Environment + * names are only unique per project, so display paths prefix this to + * disambiguate (e.g. "My Project > Staging"). + */ + projectName?: string; } export interface ClaimedEnvironmentConfig extends BaseEnvironmentConfig { @@ -108,14 +114,21 @@ export function setActiveEnvironment(name: string): void { * and opportunistic healing). No-op when the profile does not exist or already * stores the same ID — healing must never churn the keyring with no-op writes. */ -export function setProfileEnvironmentId(envKey: string, environmentId: string, environmentName?: string | null): void { +export function setProfileEnvironmentId( + envKey: string, + environmentId: string, + environmentName?: string | null, + projectName?: string | null, +): void { const config = getConfig(); const profile = config?.environments[envKey]; if (!config || !profile) return; const nameUnchanged = environmentName === undefined || profile.environmentName === (environmentName ?? undefined); - if (profile.environmentId === environmentId && nameUnchanged) return; + const projectUnchanged = projectName === undefined || profile.projectName === (projectName ?? undefined); + if (profile.environmentId === environmentId && nameUnchanged && projectUnchanged) return; profile.environmentId = environmentId; if (environmentName !== undefined) profile.environmentName = environmentName ?? undefined; + if (projectName !== undefined) profile.projectName = projectName ?? undefined; saveConfig(config); } @@ -196,6 +209,7 @@ export function markEnvironmentClaimed(): void { ...(env.ownerUserId && { ownerUserId: env.ownerUserId }), ...(env.environmentId && { environmentId: env.environmentId }), ...(env.environmentName && { environmentName: env.environmentName }), + ...(env.projectName && { projectName: env.projectName }), }; if (oldKey !== newKey) { diff --git a/src/lib/environment-target.spec.ts b/src/lib/environment-target.spec.ts index 8573e5f1..fa7eb628 100644 --- a/src/lib/environment-target.spec.ts +++ b/src/lib/environment-target.spec.ts @@ -50,7 +50,9 @@ vi.mock('node:os', async (importOriginal) => { }); const { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await import('./config-store.js'); -const { resolveEnvironmentTarget, tryResolveProfileEnvironmentId } = await import('./environment-target.js'); +const { resolveEnvironmentTarget, tryResolveProfileEnvironmentId, formatEnvironmentLabel } = await import( + './environment-target.js' +); const { DashboardGraphqlError } = await import('./dashboard-graphql.js'); const { setInteractionMode, resetInteractionModeForTests } = await import('../utils/interaction-mode.js'); const { CliExit } = await import('../utils/cli-exit.js'); @@ -409,3 +411,16 @@ describe('tryResolveProfileEnvironmentId', () => { expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); }); + +describe('formatEnvironmentLabel', () => { + it('prefixes the project name — environment names are only unique per project', () => { + expect(formatEnvironmentLabel({ id: 'env_1', name: 'Staging', projectName: 'My Project' })).toBe( + 'My Project > Staging', + ); + }); + + it('falls back to the bare name, then the id', () => { + expect(formatEnvironmentLabel({ id: 'env_1', name: 'Staging', projectName: null })).toBe('Staging'); + expect(formatEnvironmentLabel({ id: 'env_1', name: null, projectName: null })).toBe('env_1'); + }); +}); diff --git a/src/lib/environment-target.ts b/src/lib/environment-target.ts index d3d5b77a..8597bbf9 100644 --- a/src/lib/environment-target.ts +++ b/src/lib/environment-target.ts @@ -52,14 +52,26 @@ export interface TeamEnvironment { name: string | null; sandbox?: boolean | null; clientId?: string | null; + /** Owning project's name. Environment names are only unique per project. */ + projectName?: string | null; } interface TeamProjectsData { currentTeam: { - projectsV2: Array<{ environments: TeamEnvironment[] | null }> | null; + projectsV2: Array<{ name: string | null; environments: TeamEnvironment[] | null }> | null; } | null; } +/** + * "Project > Environment" when the project is known. Teams can hold multiple + * projects and environment names are only unique per project, so a bare + * "Staging" is ambiguous the moment two projects both have one. + */ +export function formatEnvironmentLabel(env: Pick): string { + const name = env.name ?? env.id; + return env.projectName ? `${env.projectName} > ${name}` : name; +} + /** The two remedies every unresolved/stale message must name. */ function remedies(): string { return `Pass --environment-id, or run \`${formatWorkOSCommand('profile switch')}\` to select an environment.`; @@ -75,7 +87,9 @@ export async function fetchTeamEnvironments(token: string): Promise(resolveExecutableDocument(op), { token }); const projects = data.currentTeam?.projectsV2 ?? []; - return projects.flatMap((project) => project.environments ?? []); + return projects.flatMap((project) => + (project.environments ?? []).map((env) => ({ ...env, projectName: project.name })), + ); } /** @@ -97,7 +111,7 @@ export function healProfiles(config: CliConfig | null, environments: TeamEnviron for (const [key, profile] of Object.entries(config.environments)) { if (!profile.clientId) continue; const match = environments.find((env) => env.clientId === profile.clientId); - if (match) setProfileEnvironmentId(key, match.id, match.name); + if (match) setProfileEnvironmentId(key, match.id, match.name, match.projectName); } } @@ -115,7 +129,7 @@ export async function promptForEnvironment(environments: TeamEnvironment[]): Pro message: 'Select the WorkOS environment to target', options: environments.map((env) => ({ value: env.id, - label: `${env.name ?? env.id}${env.sandbox ? ' [Sandbox]' : ''}`, + label: `${formatEnvironmentLabel(env)}${env.sandbox ? ' [Sandbox]' : ''}`, hint: env.id, })), }); @@ -200,7 +214,7 @@ export async function resolveEnvironmentTarget( // snapshot is still authoritative for the profile name. if (config?.activeEnvironment) { const chosen = environments.find((env) => env.id === choice); - setProfileEnvironmentId(config.activeEnvironment, choice, chosen?.name); + setProfileEnvironmentId(config.activeEnvironment, choice, chosen?.name, chosen?.projectName); } return { environmentId: choice, source: 'picker' }; } @@ -245,7 +259,7 @@ export async function tryResolveProfileEnvironmentId( if (profile.clientId) { const match = environments.find((env) => env.clientId === profile.clientId); if (match) { - setProfileEnvironmentId(envKey, match.id, match.name); + setProfileEnvironmentId(envKey, match.id, match.name, match.projectName); return true; } // A clientId that joins nothing usually means a foreign profile (an API @@ -257,7 +271,7 @@ export async function tryResolveProfileEnvironmentId( const choice = await promptForEnvironment(environments); if (choice === null) return false; // cancel skips resolution, never aborts the caller const chosen = environments.find((env) => env.id === choice); - setProfileEnvironmentId(envKey, choice, chosen?.name); + setProfileEnvironmentId(envKey, choice, chosen?.name, chosen?.projectName); return true; } From 888ee1369037e04a95c31cfd920453df03a4a57e Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 25 Aug 2026 18:52:06 -0500 Subject: [PATCH 2/3] chore: formatting --- src/lib/environment-target.spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/environment-target.spec.ts b/src/lib/environment-target.spec.ts index fa7eb628..e038fa84 100644 --- a/src/lib/environment-target.spec.ts +++ b/src/lib/environment-target.spec.ts @@ -50,9 +50,8 @@ vi.mock('node:os', async (importOriginal) => { }); const { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await import('./config-store.js'); -const { resolveEnvironmentTarget, tryResolveProfileEnvironmentId, formatEnvironmentLabel } = await import( - './environment-target.js' -); +const { resolveEnvironmentTarget, tryResolveProfileEnvironmentId, formatEnvironmentLabel } = + await import('./environment-target.js'); const { DashboardGraphqlError } = await import('./dashboard-graphql.js'); const { setInteractionMode, resetInteractionModeForTests } = await import('../utils/interaction-mode.js'); const { CliExit } = await import('../utils/cli-exit.js'); From 184f4ae4c65f379180fb5bac45973b54cc79d481 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 25 Aug 2026 18:53:41 -0500 Subject: [PATCH 3/3] test: cover the GraphQL project-name mapping end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The formatter tests passed projectName directly, leaving the actual fetchTeamEnvironments project->environment copy uncovered — a regression there would keep the formatter tests green while labels went ambiguous and profiles stopped backfilling projectName. Feed a project-name-bearing teamProjectsV2 response through tryResolveProfileEnvironmentId and assert the name persists onto the profile. Addresses PR #229 review. --- src/lib/environment-target.spec.ts | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/lib/environment-target.spec.ts b/src/lib/environment-target.spec.ts index e038fa84..ee5b9192 100644 --- a/src/lib/environment-target.spec.ts +++ b/src/lib/environment-target.spec.ts @@ -58,10 +58,15 @@ const { CliExit } = await import('../utils/cli-exit.js'); const ui = (await import('../utils/ui.js')).default; /** teamProjectsV2 response with the given environments spread over projects. */ -function teamData(environments: Array<{ id: string; name?: string; clientId?: string; sandbox?: boolean }>) { +function teamData( + environments: Array<{ id: string; name?: string; clientId?: string; sandbox?: boolean }>, + projectName: string | null = null, +) { return { currentTeam: { - projectsV2: [{ environments: environments.map((env) => ({ name: env.id, sandbox: false, ...env })) }], + projectsV2: [ + { name: projectName, environments: environments.map((env) => ({ name: env.id, sandbox: false, ...env })) }, + ], }, }; } @@ -411,6 +416,23 @@ describe('tryResolveProfileEnvironmentId', () => { }); }); +describe('project-name mapping (fetchTeamEnvironments → profile)', () => { + it('copies the GraphQL project name onto environments and persists it through healing', async () => { + seedProfile({ clientId: 'client_abc' }); + mockGraphqlRequest.mockResolvedValue( + teamData([{ id: 'env_joined', name: 'Staging', clientId: 'client_abc' }], 'My Project'), + ); + await expect(tryResolveProfileEnvironmentId('staging')).resolves.toBe(true); + const profile = getConfig()?.environments.staging; + expect(profile?.environmentId).toBe('env_joined'); + expect(profile?.environmentName).toBe('Staging'); + // The mapping under test: fetchTeamEnvironments must copy the owning + // project's name onto each environment — the formatter tests alone + // cannot catch a regression here. + expect(profile?.projectName).toBe('My Project'); + }); +}); + describe('formatEnvironmentLabel', () => { it('prefixes the project name — environment names are only unique per project', () => { expect(formatEnvironmentLabel({ id: 'env_1', name: 'Staging', projectName: 'My Project' })).toBe(