Skip to content

Commit 2e0e3ca

Browse files
authored
feat!: consolidate and rename CLI environment variables (#180)
* feat!: consolidate and rename CLI environment variables Reduce env-var sprawl in the CLI: - Derive the LLM gateway and CLI telemetry URLs from WORKOS_API_URL instead of separate WORKOS_LLM_GATEWAY_URL / WORKOS_TELEMETRY_URL vars, so api.workos.com is no longer hardcoded in multiple places. - Remove the redundant WORKOS_NO_PROMPT alias; WORKOS_MODE=agent is the single, self-documenting way to force agent interaction + JSON output. - Standardize the INSTALLER_* prefix to WORKOS_* (WORKOS_DEV, WORKOS_DISABLE_PROXY). - Fix the rotted `workos debug env` catalog (it was missing several vars) and add a test that scans src/ for WORKOS_* env reads and fails if any is missing from the catalog, preventing future drift. BREAKING CHANGE: WORKOS_LLM_GATEWAY_URL and WORKOS_TELEMETRY_URL are removed (derived from WORKOS_API_URL); WORKOS_NO_PROMPT is removed (use WORKOS_MODE=agent); INSTALLER_DEV is renamed to WORKOS_DEV and INSTALLER_DISABLE_PROXY to WORKOS_DISABLE_PROXY. * refactor: consolidate WorkOS URL resolution into utils/urls.ts Address code review on the env-var cleanup: - Move getLlmGatewayUrl/getTelemetryUrl from settings.ts into urls.ts so the derived endpoints sit next to their base (getWorkOSApiUrl). This removes the backwards settings->urls import and makes urls.ts a dependency-free leaf that is the single home for WorkOS endpoint resolution. getAuthkitDomain stays in settings.ts since it's config-backed, not API-host-derived. - Normalize the trailing slash once inside getWorkOSApiUrl instead of repeating the strip at each derivation site. - Scope the env-var-catalog guard test's claim to dot-access reads and document the forms it doesn't cover (bracket access, destructuring). - Fix a stale docstring on getCliAuthClientId (it is not env-overridable). * test: simplify and strengthen the env-var catalog guard - Replace the hand-rolled recursive file walk with fast-glob, the pattern already used across src/ (environment.ts, validator.ts, integrations). - Read source files concurrently instead of in a sequential await loop. - Make the catalog check bidirectional: it now also fails on stale entries (cataloged vars no longer read anywhere), not just missing ones, with an explicit CATALOG_ONLY escape hatch for any future non-dot-access reads. This subsumes the old WORKOS_-prefix assertion. * chore: formatting * test: stop counting env writes as reads in the catalog guard WORKOS_SECRET_KEY is only written (migrations.ts sets it for the downstream migrations SDK), never read by the CLI. The guard's regex matched the assignment's left-hand side and "discovered" it, so it was catalogued as an input credential — misleading, since the CLI ignores any user-provided value. Tighten the discovery regex to exclude assignment targets (with an end-anchor so the greedy capture can't backtrack to a truncated name), and drop the write-only WORKOS_SECRET_KEY entry. The catalog now means exactly what its docstring claims: WORKOS_ vars the CLI reads.
1 parent 899e9a8 commit 2e0e3ca

26 files changed

Lines changed: 171 additions & 116 deletions

DEVELOPMENT.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,9 @@ Guidelines for new code:
114114
- For destructive operations, require an explicit `--yes`/`--force` flag whenever `!isPromptAllowed()` regardless of output mode.
115115
- For `auth_required` and other deterministic failures, attach recovery metadata via `src/utils/recovery-hints.ts` so agents can parse `error.recovery.hints[]`.
116116

117-
Legacy compatibility — do not regress these:
117+
Mode resolution — do not regress these:
118118

119-
- `WORKOS_NO_PROMPT=1` keeps mapping to agent interaction behavior **and** JSON output (legacy alias).
119+
- `WORKOS_MODE=agent` maps to agent interaction behavior **and** JSON output (via `resolveEffectiveOutputMode`). The old `WORKOS_NO_PROMPT` alias has been removed.
120120
- `WORKOS_FORCE_TTY=1` only affects output mode (forces human). It must not change interaction mode.
121121
- Non-TTY stdout still defaults output to JSON and interaction to agent.
122122
- `isNonInteractiveEnvironment()` from `src/utils/environment.ts` is a thin wrapper over `!isHumanMode()` kept for backward compatibility. Prefer the explicit interaction-mode predicates in new code.

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -663,10 +663,10 @@ In agent mode the CLI:
663663
664664
In `ci` mode the CLI additionally refuses browser-based auth flows and prefers terse failures over recovery handoff text.
665665
666-
Legacy compatibility:
666+
Mode resolution notes:
667667
668-
- `WORKOS_NO_PROMPT=1` continues to work and is treated as agent interaction behavior plus JSON output.
669-
- `WORKOS_FORCE_TTY=1` continues to force human **output** mode but does not change interaction mode.
668+
- `WORKOS_MODE=agent` sets agent interaction behavior and forces JSON output. (This replaces the removed `WORKOS_NO_PROMPT` alias.)
669+
- `WORKOS_FORCE_TTY=1` forces human **output** mode but does not change interaction mode.
670670
- Non-TTY without an explicit mode still defaults output to JSON and interaction to agent.
671671
672672
### Headless Installer
@@ -697,7 +697,6 @@ workos install --api-key sk_test_xxx --client-id client_xxx --no-commit 2>/dev/n
697697
| `WORKOS_API_KEY` | API key for management commands (bypasses stored config) |
698698
| `WORKOS_API_BASE_URL` | Override API base URL (set automatically by `workos dev`) |
699699
| `WORKOS_MODE` | Interaction mode: `human`, `agent`, or `ci` |
700-
| `WORKOS_NO_PROMPT=1` | Legacy alias: agent interaction behavior + JSON output |
701700
| `WORKOS_FORCE_TTY=1` | Force human (non-JSON) **output** mode even when piped |
702701
| `WORKOS_TELEMETRY=false` | Disable telemetry |
703702

src/bin-command-telemetry.integration.spec.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,11 @@ function runCli(args: string[], envOverrides: NodeJS.ProcessEnv = {}) {
5959
// silently produce no event and fail. Tests that exercise env precedence
6060
// override this explicitly via envOverrides.
6161
WORKOS_TELEMETRY: 'true',
62-
// Unroutable URL: the flush fails, so the queued events are persisted to
63-
// the pending file on exit where we can inspect the real payload.
64-
WORKOS_TELEMETRY_URL: 'http://127.0.0.1:59999/cli',
62+
// Unroutable API base: telemetry derives ${WORKOS_API_URL}/cli, so the
63+
// flush fails and the queued events are persisted to the pending file on
64+
// exit where we can inspect the real payload. (The tested commands fail
65+
// validation / crash before any real API call, so this host is never hit.)
66+
WORKOS_API_URL: 'http://127.0.0.1:59999',
6567
WORKOS_API_KEY: 'sk_dummy_for_test',
6668
...envOverrides,
6769
};

src/bin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env node
22

33
// Load .env.local for local development when --local flag is used
4-
if (process.argv.includes('--local') || process.env.INSTALLER_DEV) {
4+
if (process.argv.includes('--local') || process.env.WORKOS_DEV) {
55
const { config } = await import('dotenv');
66
// bin.ts compiles to dist/bin.js, so go up one level to find .env.local
77
const { fileURLToPath } = await import('node:url');

src/cli.config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ export const config = {
77
model: 'claude-opus-4-5-20251101',
88
doctorModel: 'claude-haiku-4-5-20251001',
99

10-
// Production defaults - override via env vars for local dev
10+
// Production defaults - override via env vars for local dev.
11+
// The LLM gateway and CLI telemetry endpoints live under the WorkOS API
12+
// host, so they're derived from WORKOS_API_URL rather than configured here.
1113
workos: {
1214
clientId: 'client_01KFKHSZWK9ADVJV854PDFQCCR',
1315
authkitDomain: 'https://signin.workos.com',
14-
llmGatewayUrl: 'https://api.workos.com/llm-gateway',
15-
telemetryUrl: 'https://api.workos.com/cli',
1616
},
1717

1818
telemetry: {

src/commands/debug.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -593,16 +593,16 @@ describe('debug commands', () => {
593593

594594
it('outputs valid JSON in json mode', async () => {
595595
jsonMode = true;
596-
process.env.WORKOS_NO_PROMPT = '1';
596+
process.env.WORKOS_DEBUG = '1';
597597

598598
await runDebugEnv();
599599

600600
const parsed = JSON.parse(consoleOutput[0]);
601-
expect(parsed.variables.WORKOS_NO_PROMPT.value).toBe('1');
602-
expect(parsed.set).toContain('WORKOS_NO_PROMPT');
603-
expect(parsed.unset).not.toContain('WORKOS_NO_PROMPT');
601+
expect(parsed.variables.WORKOS_DEBUG.value).toBe('1');
602+
expect(parsed.set).toContain('WORKOS_DEBUG');
603+
expect(parsed.unset).not.toContain('WORKOS_DEBUG');
604604

605-
delete process.env.WORKOS_NO_PROMPT;
605+
delete process.env.WORKOS_DEBUG;
606606
});
607607

608608
it('lists all known env vars', async () => {
@@ -614,7 +614,7 @@ describe('debug commands', () => {
614614
expect(Object.keys(parsed.variables)).toContain('WORKOS_API_KEY');
615615
expect(Object.keys(parsed.variables)).toContain('WORKOS_FORCE_TTY');
616616
expect(Object.keys(parsed.variables)).toContain('WORKOS_TELEMETRY');
617-
expect(Object.keys(parsed.variables)).toContain('INSTALLER_DEV');
617+
expect(Object.keys(parsed.variables)).toContain('WORKOS_DEV');
618618
});
619619
});
620620
});

src/commands/debug.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -371,20 +371,36 @@ interface EnvVarInfo {
371371
effect: string;
372372
}
373373

374-
const ENV_VAR_CATALOG: { name: string; effect: string }[] = [
375-
{ name: 'WORKOS_DEBUG', effect: 'Set to "1" to enable verbose debug logging for all commands' },
374+
/**
375+
* Catalog of WORKOS_-prefixed environment variables the CLI reads.
376+
*
377+
* This is the single source of truth for `workos debug env`. A unit test
378+
* (env-var-catalog.spec.ts) scans the source for `process.env.WORKOS_*` reads
379+
* and fails if any are missing here, so this list can't silently drift.
380+
*/
381+
export const ENV_VAR_CATALOG: { name: string; effect: string }[] = [
382+
// Credentials
376383
{ name: 'WORKOS_API_KEY', effect: 'Bypasses credential resolution — used directly for API calls' },
384+
{ name: 'WORKOS_CLIENT_ID', effect: 'WorkOS client ID used during credential resolution' },
385+
// Interaction & output
377386
{ name: 'WORKOS_MODE', effect: 'Controls interaction behavior: human, agent, or CI' },
387+
{ name: 'WORKOS_AGENT', effect: 'Set to "1" to force agent interaction mode' },
378388
{ name: 'WORKOS_FORCE_TTY', effect: 'Forces human (non-JSON) output mode, even when piped' },
379-
{ name: 'WORKOS_NO_PROMPT', effect: 'Legacy compatibility alias for agent interaction behavior and JSON output' },
389+
{ name: 'WORKOS_DEBUG', effect: 'Set to "1" to enable verbose debug logging for all commands' },
380390
{ name: 'WORKOS_TELEMETRY', effect: 'Set to "false" to disable telemetry' },
381-
{ name: 'WORKOS_API_URL', effect: 'Overrides API base URL (default: https://api.workos.com)' },
391+
// URLs (WORKOS_API_URL is the single base; gateway + telemetry derive from it)
392+
{
393+
name: 'WORKOS_API_URL',
394+
effect: 'Overrides API base URL; also reroutes the LLM gateway and CLI telemetry endpoints',
395+
},
382396
{ name: 'WORKOS_DASHBOARD_URL', effect: 'Overrides dashboard URL (default: https://dashboard.workos.com)' },
383397
{ name: 'WORKOS_AUTHKIT_DOMAIN', effect: 'Overrides AuthKit domain from settings' },
384-
{ name: 'WORKOS_LLM_GATEWAY_URL', effect: 'Overrides LLM gateway URL from settings' },
385-
{ name: 'WORKOS_TELEMETRY_URL', effect: 'Overrides CLI telemetry URL from settings' },
386-
{ name: 'INSTALLER_DEV', effect: 'Enables dev mode — loads .env.local at startup' },
387-
{ name: 'INSTALLER_DISABLE_PROXY', effect: 'Disables the credential proxy for gateway auth' },
398+
{ name: 'WORKOS_BASE_URL', effect: 'AuthKit base URL, read during doctor environment checks' },
399+
{ name: 'WORKOS_REDIRECT_URI', effect: 'OAuth redirect URI, read during doctor environment checks' },
400+
{ name: 'WORKOS_COOKIE_DOMAIN', effect: 'Session cookie domain, read during doctor environment checks' },
401+
// Development
402+
{ name: 'WORKOS_DEV', effect: 'Enables dev mode — loads .env.local at startup' },
403+
{ name: 'WORKOS_DISABLE_PROXY', effect: 'Disables the credential proxy for gateway auth' },
388404
];
389405

390406
export async function runDebugEnv(): Promise<void> {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { readFile } from 'node:fs/promises';
3+
import { fileURLToPath } from 'node:url';
4+
import fg from 'fast-glob';
5+
import { ENV_VAR_CATALOG } from './debug.js';
6+
7+
// Resolve the src/ root from this file's location (src/commands/*.spec.ts).
8+
const SRC_DIR = fileURLToPath(new URL('..', import.meta.url));
9+
10+
// Matches dot-access env READS: `process.env.WORKOS_X` and the destructured
11+
// `env.WORKOS_X` form (e.g. interaction-mode.ts). Three guards, in order:
12+
// - lookbehind `(?<![\w$])` excludes `projectEnv.WORKOS_X` / `sdkEnv.WORKOS_X`
13+
// - `(?![A-Z0-9_])` anchors the end of the name so the greedy capture can't
14+
// backtrack to a truncated match to satisfy the next lookahead
15+
// - `(?!\s*=[^=])` excludes assignment targets (`process.env.WORKOS_X = ...`)
16+
// so vars the CLI only *writes* for a downstream SDK aren't counted as reads;
17+
// `===`/`!==` comparisons still match (it only rejects a lone `=`)
18+
//
19+
// Coverage is limited to dot access — it does NOT catch bracket access
20+
// (`process.env['WORKOS_X']`) or destructuring (`const { WORKOS_X } = process.env`).
21+
// No such reads exist today; if one is introduced, list it in CATALOG_ONLY below
22+
// so the bidirectional check still passes.
23+
const ENV_READ_PATTERN = /(?:process\.env|(?<![\w$])env)\.(WORKOS_[A-Z0-9_]+)(?![A-Z0-9_])(?!\s*=[^=])/g;
24+
25+
// WORKOS_ vars that belong in the catalog but the scan can't see (non-dot-access
26+
// reads). Empty today — kept as the explicit escape hatch for the limitation above.
27+
const CATALOG_ONLY = new Set<string>();
28+
29+
async function discoverEnvReads(): Promise<Set<string>> {
30+
const files = await fg('**/*.ts', {
31+
cwd: SRC_DIR,
32+
absolute: true,
33+
ignore: ['**/*.spec.ts', '**/*.d.ts'],
34+
});
35+
const contents = await Promise.all(files.map((file) => readFile(file, 'utf-8')));
36+
const reads = new Set<string>();
37+
for (const text of contents) {
38+
for (const match of text.matchAll(ENV_READ_PATTERN)) reads.add(match[1]);
39+
}
40+
return reads;
41+
}
42+
43+
describe('WORKOS_ env var catalog (debug env)', () => {
44+
it('stays in sync with the WORKOS_ env vars the CLI reads (no missing or stale entries)', async () => {
45+
const discovered = await discoverEnvReads();
46+
const cataloged = new Set(ENV_VAR_CATALOG.map((v) => v.name));
47+
48+
const missing = [...discovered].filter((name) => !cataloged.has(name)).sort();
49+
const stale = [...cataloged].filter((name) => !discovered.has(name) && !CATALOG_ONLY.has(name)).sort();
50+
51+
expect(missing, `Read in src/ but missing from ENV_VAR_CATALOG (debug.ts): ${missing.join(', ')}`).toEqual([]);
52+
expect(
53+
stale,
54+
`In ENV_VAR_CATALOG (debug.ts) but no longer read in src/ — remove it, or add to CATALOG_ONLY if intentional: ${stale.join(', ')}`,
55+
).toEqual([]);
56+
});
57+
58+
it('has no duplicate entries', () => {
59+
const names = ENV_VAR_CATALOG.map((v) => v.name);
60+
expect(new Set(names).size).toBe(names.length);
61+
});
62+
});

src/doctor/checks/ai-analysis.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Anthropic from '@anthropic-ai/sdk';
2-
import { getLlmGatewayUrl, getAuthkitDomain, getCliAuthClientId, getConfig } from '../../lib/settings.js';
2+
import { getAuthkitDomain, getCliAuthClientId, getConfig } from '../../lib/settings.js';
3+
import { getLlmGatewayUrl } from '../../utils/urls.js';
34
import { getCredentials, isTokenExpired, updateTokens, diagnoseCredentials } from '../../lib/credentials.js';
45
import { refreshAccessToken } from '../../lib/token-refresh-client.js';
56
import { buildDoctorPrompt, type AnalysisContext } from '../agent-prompt.js';

src/doctor/output.spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ describe('doctor output', () => {
3939
it('formats interaction mode sources for human output', () => {
4040
expect(formatInteractionModeSource('flag')).toBe('--mode');
4141
expect(formatInteractionModeSource('env')).toBe('WORKOS_MODE');
42-
expect(formatInteractionModeSource('workos_no_prompt')).toBe('WORKOS_NO_PROMPT');
4342
expect(formatInteractionModeSource('ci_env')).toBe('CI environment');
4443
expect(formatInteractionModeSource('agent_env')).toBe('agent environment');
4544
expect(formatInteractionModeSource('non_tty')).toBe('non-TTY');

0 commit comments

Comments
 (0)