diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bc171c8d..bee7f991 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -114,9 +114,9 @@ Guidelines for new code: - For destructive operations, require an explicit `--yes`/`--force` flag whenever `!isPromptAllowed()` regardless of output mode. - For `auth_required` and other deterministic failures, attach recovery metadata via `src/utils/recovery-hints.ts` so agents can parse `error.recovery.hints[]`. -Legacy compatibility — do not regress these: +Mode resolution — do not regress these: -- `WORKOS_NO_PROMPT=1` keeps mapping to agent interaction behavior **and** JSON output (legacy alias). +- `WORKOS_MODE=agent` maps to agent interaction behavior **and** JSON output (via `resolveEffectiveOutputMode`). The old `WORKOS_NO_PROMPT` alias has been removed. - `WORKOS_FORCE_TTY=1` only affects output mode (forces human). It must not change interaction mode. - Non-TTY stdout still defaults output to JSON and interaction to agent. - `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. diff --git a/README.md b/README.md index f958c5ad..b7187061 100644 --- a/README.md +++ b/README.md @@ -663,10 +663,10 @@ In agent mode the CLI: In `ci` mode the CLI additionally refuses browser-based auth flows and prefers terse failures over recovery handoff text. -Legacy compatibility: +Mode resolution notes: -- `WORKOS_NO_PROMPT=1` continues to work and is treated as agent interaction behavior plus JSON output. -- `WORKOS_FORCE_TTY=1` continues to force human **output** mode but does not change interaction mode. +- `WORKOS_MODE=agent` sets agent interaction behavior and forces JSON output. (This replaces the removed `WORKOS_NO_PROMPT` alias.) +- `WORKOS_FORCE_TTY=1` forces human **output** mode but does not change interaction mode. - Non-TTY without an explicit mode still defaults output to JSON and interaction to agent. ### Headless Installer @@ -697,7 +697,6 @@ workos install --api-key sk_test_xxx --client-id client_xxx --no-commit 2>/dev/n | `WORKOS_API_KEY` | API key for management commands (bypasses stored config) | | `WORKOS_API_BASE_URL` | Override API base URL (set automatically by `workos dev`) | | `WORKOS_MODE` | Interaction mode: `human`, `agent`, or `ci` | -| `WORKOS_NO_PROMPT=1` | Legacy alias: agent interaction behavior + JSON output | | `WORKOS_FORCE_TTY=1` | Force human (non-JSON) **output** mode even when piped | | `WORKOS_TELEMETRY=false` | Disable telemetry | diff --git a/src/bin-command-telemetry.integration.spec.ts b/src/bin-command-telemetry.integration.spec.ts index a3600d2e..3e288ce1 100644 --- a/src/bin-command-telemetry.integration.spec.ts +++ b/src/bin-command-telemetry.integration.spec.ts @@ -59,9 +59,11 @@ function runCli(args: string[], envOverrides: NodeJS.ProcessEnv = {}) { // silently produce no event and fail. Tests that exercise env precedence // override this explicitly via envOverrides. WORKOS_TELEMETRY: 'true', - // Unroutable URL: the flush fails, so the queued events are persisted to - // the pending file on exit where we can inspect the real payload. - WORKOS_TELEMETRY_URL: 'http://127.0.0.1:59999/cli', + // Unroutable API base: telemetry derives ${WORKOS_API_URL}/cli, so the + // flush fails and the queued events are persisted to the pending file on + // exit where we can inspect the real payload. (The tested commands fail + // validation / crash before any real API call, so this host is never hit.) + WORKOS_API_URL: 'http://127.0.0.1:59999', WORKOS_API_KEY: 'sk_dummy_for_test', ...envOverrides, }; diff --git a/src/bin.ts b/src/bin.ts index 4cc0122f..6eb01b67 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node // Load .env.local for local development when --local flag is used -if (process.argv.includes('--local') || process.env.INSTALLER_DEV) { +if (process.argv.includes('--local') || process.env.WORKOS_DEV) { const { config } = await import('dotenv'); // bin.ts compiles to dist/bin.js, so go up one level to find .env.local const { fileURLToPath } = await import('node:url'); diff --git a/src/cli.config.ts b/src/cli.config.ts index bbad7ff1..9b1629b2 100644 --- a/src/cli.config.ts +++ b/src/cli.config.ts @@ -7,12 +7,12 @@ export const config = { model: 'claude-opus-4-5-20251101', doctorModel: 'claude-haiku-4-5-20251001', - // Production defaults - override via env vars for local dev + // Production defaults - override via env vars for local dev. + // The LLM gateway and CLI telemetry endpoints live under the WorkOS API + // host, so they're derived from WORKOS_API_URL rather than configured here. workos: { clientId: 'client_01KFKHSZWK9ADVJV854PDFQCCR', authkitDomain: 'https://signin.workos.com', - llmGatewayUrl: 'https://api.workos.com/llm-gateway', - telemetryUrl: 'https://api.workos.com/cli', }, telemetry: { diff --git a/src/commands/debug.spec.ts b/src/commands/debug.spec.ts index 902e5bca..61f17cd1 100644 --- a/src/commands/debug.spec.ts +++ b/src/commands/debug.spec.ts @@ -593,16 +593,16 @@ describe('debug commands', () => { it('outputs valid JSON in json mode', async () => { jsonMode = true; - process.env.WORKOS_NO_PROMPT = '1'; + process.env.WORKOS_DEBUG = '1'; await runDebugEnv(); const parsed = JSON.parse(consoleOutput[0]); - expect(parsed.variables.WORKOS_NO_PROMPT.value).toBe('1'); - expect(parsed.set).toContain('WORKOS_NO_PROMPT'); - expect(parsed.unset).not.toContain('WORKOS_NO_PROMPT'); + expect(parsed.variables.WORKOS_DEBUG.value).toBe('1'); + expect(parsed.set).toContain('WORKOS_DEBUG'); + expect(parsed.unset).not.toContain('WORKOS_DEBUG'); - delete process.env.WORKOS_NO_PROMPT; + delete process.env.WORKOS_DEBUG; }); it('lists all known env vars', async () => { @@ -614,7 +614,7 @@ describe('debug commands', () => { expect(Object.keys(parsed.variables)).toContain('WORKOS_API_KEY'); expect(Object.keys(parsed.variables)).toContain('WORKOS_FORCE_TTY'); expect(Object.keys(parsed.variables)).toContain('WORKOS_TELEMETRY'); - expect(Object.keys(parsed.variables)).toContain('INSTALLER_DEV'); + expect(Object.keys(parsed.variables)).toContain('WORKOS_DEV'); }); }); }); diff --git a/src/commands/debug.ts b/src/commands/debug.ts index e7f0da9d..675df5b4 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -371,20 +371,36 @@ interface EnvVarInfo { effect: string; } -const ENV_VAR_CATALOG: { name: string; effect: string }[] = [ - { name: 'WORKOS_DEBUG', effect: 'Set to "1" to enable verbose debug logging for all commands' }, +/** + * Catalog of WORKOS_-prefixed environment variables the CLI reads. + * + * This is the single source of truth for `workos debug env`. A unit test + * (env-var-catalog.spec.ts) scans the source for `process.env.WORKOS_*` reads + * and fails if any are missing here, so this list can't silently drift. + */ +export const ENV_VAR_CATALOG: { name: string; effect: string }[] = [ + // Credentials { name: 'WORKOS_API_KEY', effect: 'Bypasses credential resolution — used directly for API calls' }, + { name: 'WORKOS_CLIENT_ID', effect: 'WorkOS client ID used during credential resolution' }, + // Interaction & output { name: 'WORKOS_MODE', effect: 'Controls interaction behavior: human, agent, or CI' }, + { name: 'WORKOS_AGENT', effect: 'Set to "1" to force agent interaction mode' }, { name: 'WORKOS_FORCE_TTY', effect: 'Forces human (non-JSON) output mode, even when piped' }, - { name: 'WORKOS_NO_PROMPT', effect: 'Legacy compatibility alias for agent interaction behavior and JSON output' }, + { name: 'WORKOS_DEBUG', effect: 'Set to "1" to enable verbose debug logging for all commands' }, { name: 'WORKOS_TELEMETRY', effect: 'Set to "false" to disable telemetry' }, - { name: 'WORKOS_API_URL', effect: 'Overrides API base URL (default: https://api.workos.com)' }, + // URLs (WORKOS_API_URL is the single base; gateway + telemetry derive from it) + { + name: 'WORKOS_API_URL', + effect: 'Overrides API base URL; also reroutes the LLM gateway and CLI telemetry endpoints', + }, { name: 'WORKOS_DASHBOARD_URL', effect: 'Overrides dashboard URL (default: https://dashboard.workos.com)' }, { name: 'WORKOS_AUTHKIT_DOMAIN', effect: 'Overrides AuthKit domain from settings' }, - { name: 'WORKOS_LLM_GATEWAY_URL', effect: 'Overrides LLM gateway URL from settings' }, - { name: 'WORKOS_TELEMETRY_URL', effect: 'Overrides CLI telemetry URL from settings' }, - { name: 'INSTALLER_DEV', effect: 'Enables dev mode — loads .env.local at startup' }, - { name: 'INSTALLER_DISABLE_PROXY', effect: 'Disables the credential proxy for gateway auth' }, + { name: 'WORKOS_BASE_URL', effect: 'AuthKit base URL, read during doctor environment checks' }, + { name: 'WORKOS_REDIRECT_URI', effect: 'OAuth redirect URI, read during doctor environment checks' }, + { name: 'WORKOS_COOKIE_DOMAIN', effect: 'Session cookie domain, read during doctor environment checks' }, + // Development + { name: 'WORKOS_DEV', effect: 'Enables dev mode — loads .env.local at startup' }, + { name: 'WORKOS_DISABLE_PROXY', effect: 'Disables the credential proxy for gateway auth' }, ]; export async function runDebugEnv(): Promise { diff --git a/src/commands/env-var-catalog.spec.ts b/src/commands/env-var-catalog.spec.ts new file mode 100644 index 00000000..50e6e425 --- /dev/null +++ b/src/commands/env-var-catalog.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import fg from 'fast-glob'; +import { ENV_VAR_CATALOG } from './debug.js'; + +// Resolve the src/ root from this file's location (src/commands/*.spec.ts). +const SRC_DIR = fileURLToPath(new URL('..', import.meta.url)); + +// Matches dot-access env READS: `process.env.WORKOS_X` and the destructured +// `env.WORKOS_X` form (e.g. interaction-mode.ts). Three guards, in order: +// - lookbehind `(?(); + +async function discoverEnvReads(): Promise> { + const files = await fg('**/*.ts', { + cwd: SRC_DIR, + absolute: true, + ignore: ['**/*.spec.ts', '**/*.d.ts'], + }); + const contents = await Promise.all(files.map((file) => readFile(file, 'utf-8'))); + const reads = new Set(); + for (const text of contents) { + for (const match of text.matchAll(ENV_READ_PATTERN)) reads.add(match[1]); + } + return reads; +} + +describe('WORKOS_ env var catalog (debug env)', () => { + it('stays in sync with the WORKOS_ env vars the CLI reads (no missing or stale entries)', async () => { + const discovered = await discoverEnvReads(); + const cataloged = new Set(ENV_VAR_CATALOG.map((v) => v.name)); + + const missing = [...discovered].filter((name) => !cataloged.has(name)).sort(); + const stale = [...cataloged].filter((name) => !discovered.has(name) && !CATALOG_ONLY.has(name)).sort(); + + expect(missing, `Read in src/ but missing from ENV_VAR_CATALOG (debug.ts): ${missing.join(', ')}`).toEqual([]); + expect( + stale, + `In ENV_VAR_CATALOG (debug.ts) but no longer read in src/ — remove it, or add to CATALOG_ONLY if intentional: ${stale.join(', ')}`, + ).toEqual([]); + }); + + it('has no duplicate entries', () => { + const names = ENV_VAR_CATALOG.map((v) => v.name); + expect(new Set(names).size).toBe(names.length); + }); +}); diff --git a/src/doctor/checks/ai-analysis.ts b/src/doctor/checks/ai-analysis.ts index 90d2e1e5..0e135c0e 100644 --- a/src/doctor/checks/ai-analysis.ts +++ b/src/doctor/checks/ai-analysis.ts @@ -1,5 +1,6 @@ import Anthropic from '@anthropic-ai/sdk'; -import { getLlmGatewayUrl, getAuthkitDomain, getCliAuthClientId, getConfig } from '../../lib/settings.js'; +import { getAuthkitDomain, getCliAuthClientId, getConfig } from '../../lib/settings.js'; +import { getLlmGatewayUrl } from '../../utils/urls.js'; import { getCredentials, isTokenExpired, updateTokens, diagnoseCredentials } from '../../lib/credentials.js'; import { refreshAccessToken } from '../../lib/token-refresh-client.js'; import { buildDoctorPrompt, type AnalysisContext } from '../agent-prompt.js'; diff --git a/src/doctor/output.spec.ts b/src/doctor/output.spec.ts index 2e647065..fc98e086 100644 --- a/src/doctor/output.spec.ts +++ b/src/doctor/output.spec.ts @@ -39,7 +39,6 @@ describe('doctor output', () => { it('formats interaction mode sources for human output', () => { expect(formatInteractionModeSource('flag')).toBe('--mode'); expect(formatInteractionModeSource('env')).toBe('WORKOS_MODE'); - expect(formatInteractionModeSource('workos_no_prompt')).toBe('WORKOS_NO_PROMPT'); expect(formatInteractionModeSource('ci_env')).toBe('CI environment'); expect(formatInteractionModeSource('agent_env')).toBe('agent environment'); expect(formatInteractionModeSource('non_tty')).toBe('non-TTY'); diff --git a/src/doctor/output.ts b/src/doctor/output.ts index d078e52d..49532ec3 100644 --- a/src/doctor/output.ts +++ b/src/doctor/output.ts @@ -267,8 +267,6 @@ export function formatInteractionModeSource(source: InteractionModeSource): stri return '--mode'; case 'env': return 'WORKOS_MODE'; - case 'workos_no_prompt': - return 'WORKOS_NO_PROMPT'; case 'ci_env': return 'CI environment'; case 'agent_env': diff --git a/src/lib/agent-interface.spec.ts b/src/lib/agent-interface.spec.ts index 98848dcc..bc8fc5ae 100644 --- a/src/lib/agent-interface.spec.ts +++ b/src/lib/agent-interface.spec.ts @@ -7,8 +7,6 @@ const { mockQuery, mockConfig } = vi.hoisted(() => ({ workos: { clientId: 'client_test', authkitDomain: 'test.workos.com', - llmGatewayUrl: 'http://localhost:8000', - telemetryUrl: 'http://localhost:8000/cli', }, telemetry: { enabled: false, eventName: 'test_event' }, proxy: { refreshThresholdMs: 300000 }, @@ -55,6 +53,10 @@ vi.mock('./settings.js', () => ({ getCliAuthClientId: vi.fn(() => 'client_test'), })); +vi.mock('../utils/urls.js', () => ({ + getLlmGatewayUrl: vi.fn(() => 'http://localhost:8000'), +})); + vi.mock('./credentials.js', () => ({ hasCredentials: vi.fn(() => false), getCredentials: vi.fn(() => null), @@ -74,10 +76,6 @@ vi.mock('./config-store.js', () => ({ isUnclaimedEnvironment: vi.fn(() => false), })); -vi.mock('../utils/urls.js', () => ({ - getLlmGatewayUrlFromHost: vi.fn(() => 'http://localhost:8000'), -})); - import { runAgent, AgentErrorType, initializeAgent, type AgentConfig } from './agent-interface.js'; import { startCredentialProxy, startClaimTokenProxy } from './credential-proxy.js'; import { getActiveEnvironment, isUnclaimedEnvironment } from './config-store.js'; diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index eba4a581..d839e86b 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -10,9 +10,9 @@ import type { InstallerOptions } from '../utils/types.js'; import { analytics } from '../utils/analytics.js'; import { INSTALLER_INTERACTION_EVENT_NAME } from './constants.js'; import { LINTING_TOOLS } from './safe-tools.js'; -import { getLlmGatewayUrlFromHost } from '../utils/urls.js'; import { formatWorkOSCommand } from '../utils/command-invocation.js'; import { getConfig } from './settings.js'; +import { getLlmGatewayUrl } from '../utils/urls.js'; import { getCredentials, hasCredentials } from './credentials.js'; import { ensureValidToken } from './token-refresh.js'; import type { InstallerEventEmitter } from './events.js'; @@ -375,7 +375,7 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt analytics.setTag('api_mode', 'direct'); } else { // Gateway mode (existing behavior) - const gatewayUrl = getLlmGatewayUrlFromHost(); + const gatewayUrl = getLlmGatewayUrl(); // Check for unclaimed environment — use claim token auth const activeEnv = getActiveEnvironment(); @@ -405,7 +405,7 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt } // Check if we have refresh token capability and proxy is not disabled - if (creds.refreshToken && process.env.INSTALLER_DISABLE_PROXY !== '1') { + if (creds.refreshToken && process.env.WORKOS_DISABLE_PROXY !== '1') { // Start credential proxy with lazy refresh logInfo('[agent-interface] Starting credential proxy with lazy refresh...'); const appConfig = getConfig(); @@ -447,7 +447,7 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt message: `Note: Run \`${formatWorkOSCommand('auth login')}\` to enable extended sessions`, }); } else { - logWarn('[agent-interface] Proxy disabled via INSTALLER_DISABLE_PROXY'); + logWarn('[agent-interface] Proxy disabled via WORKOS_DISABLE_PROXY'); } const refreshResult = await ensureValidToken(); diff --git a/src/lib/ai-content.ts b/src/lib/ai-content.ts index be0a419e..ec6b6eb2 100644 --- a/src/lib/ai-content.ts +++ b/src/lib/ai-content.ts @@ -1,6 +1,7 @@ import Anthropic from '@anthropic-ai/sdk'; import { startCredentialProxy } from './credential-proxy.js'; -import { getLlmGatewayUrl, getAuthkitDomain, getCliAuthClientId, getConfig } from './settings.js'; +import { getAuthkitDomain, getCliAuthClientId, getConfig } from './settings.js'; +import { getLlmGatewayUrl } from '../utils/urls.js'; import { getCredentials } from './credentials.js'; import { logInfo, logError } from '../utils/debug.js'; diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index fd8ea46a..34fda45b 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -28,7 +28,8 @@ import { getConfig, saveConfig, getActiveEnvironment, isUnclaimedEnvironment } f 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, getTelemetryUrl } from './settings.js'; +import { getCliAuthClientId, getAuthkitDomain } from './settings.js'; +import { getTelemetryUrl } from '../utils/urls.js'; import { analytics } from '../utils/analytics.js'; import { getVersion } from './settings.js'; import { isInGitRepo, getUncommittedOrUntrackedFiles } from '../utils/clack-utils.js'; diff --git a/src/lib/settings.ts b/src/lib/settings.ts index d012a6b1..233a660c 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -13,8 +13,6 @@ export interface InstallerConfig { workos: { clientId: string; authkitDomain: string; - llmGatewayUrl: string; - telemetryUrl: string; }; telemetry: { enabled: boolean; @@ -57,8 +55,7 @@ export function getConfig(): InstallerConfig { } /** - * Get the CLI auth client ID. - * Env var overrides config default. + * Get the CLI auth client ID (from config; not env-overridable). */ export function getCliAuthClientId(): string { return config.workos.clientId; @@ -67,23 +64,11 @@ export function getCliAuthClientId(): string { /** * Get the AuthKit domain. * Env var overrides config default. + * + * Note: WorkOS service endpoints (API, dashboard, LLM gateway, telemetry) + * live in utils/urls.ts. AuthKit's domain stays here because it's config- + * backed rather than derived from the API host. */ export function getAuthkitDomain(): string { return process.env.WORKOS_AUTHKIT_DOMAIN || config.workos.authkitDomain; } - -/** - * Get the LLM gateway URL. - * Env var overrides config default. - */ -export function getLlmGatewayUrl(): string { - return process.env.WORKOS_LLM_GATEWAY_URL || config.workos.llmGatewayUrl; -} - -/** - * Get the CLI telemetry URL. - * Env var overrides config default. - */ -export function getTelemetryUrl(): string { - return process.env.WORKOS_TELEMETRY_URL || config.workos.telemetryUrl; -} diff --git a/src/utils/analytics.spec.ts b/src/utils/analytics.spec.ts index 08e8ea88..af8c8d52 100644 --- a/src/utils/analytics.spec.ts +++ b/src/utils/analytics.spec.ts @@ -63,11 +63,14 @@ const mockSettingsConfig = { legacy: { oauthPort: 3000 }, }; vi.mock('../lib/settings.js', () => ({ - getTelemetryUrl: () => mockGetTelemetryUrl(), getConfig: () => mockSettingsConfig, getVersion: () => '0.12.1', })); +vi.mock('./urls.js', () => ({ + getTelemetryUrl: () => mockGetTelemetryUrl(), +})); + // Mock credentials for initForNonInstaller const mockGetCredentials = vi.fn(); vi.mock('../lib/credentials.js', () => ({ @@ -119,10 +122,12 @@ describe('Analytics', () => { }, })); vi.doMock('../lib/settings.js', () => ({ - getTelemetryUrl: () => mockGetTelemetryUrl(), getConfig: () => mockSettingsConfig, getVersion: () => '0.12.1', })); + vi.doMock('./urls.js', () => ({ + getTelemetryUrl: () => mockGetTelemetryUrl(), + })); vi.doMock('../lib/credentials.js', () => ({ getCredentials: () => mockGetCredentials(), isTokenExpired: (creds: { expiresAt: number }) => Date.now() >= creds.expiresAt, @@ -826,10 +831,12 @@ describe('Analytics', () => { }, })); vi.doMock('../lib/settings.js', () => ({ - getTelemetryUrl: () => mockGetTelemetryUrl(), getConfig: () => mockSettingsConfig, getVersion: () => '0.12.1', })); + vi.doMock('./urls.js', () => ({ + getTelemetryUrl: () => mockGetTelemetryUrl(), + })); vi.doMock('../lib/credentials.js', () => ({ getCredentials: () => mockGetCredentials(), isTokenExpired: (creds: { expiresAt: number }) => Date.now() >= creds.expiresAt, diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 5ee06637..1f529375 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -16,7 +16,8 @@ import type { EnvFingerprint, } from './telemetry-types.js'; import { isTelemetryEnabled } from '../lib/preferences.js'; -import { getTelemetryUrl, getVersion } from '../lib/settings.js'; +import { getVersion } from '../lib/settings.js'; +import { getTelemetryUrl } from './urls.js'; import { getCredentials, isTokenExpired } from '../lib/credentials.js'; import { getActiveEnvironment, isUnclaimedEnvironment } from '../lib/config-store.js'; import { getDeviceId } from '../lib/device-id.js'; diff --git a/src/utils/environment.spec.ts b/src/utils/environment.spec.ts index b955bbcd..933e663c 100644 --- a/src/utils/environment.spec.ts +++ b/src/utils/environment.spec.ts @@ -14,7 +14,7 @@ describe('environment', () => { }); it('returns true in agent interaction mode', () => { - setInteractionMode({ mode: 'agent', source: 'workos_no_prompt' }); + setInteractionMode({ mode: 'agent', source: 'agent_env' }); expect(isNonInteractiveEnvironment()).toBe(true); }); diff --git a/src/utils/interaction-mode.spec.ts b/src/utils/interaction-mode.spec.ts index aba6b725..8ce88625 100644 --- a/src/utils/interaction-mode.spec.ts +++ b/src/utils/interaction-mode.spec.ts @@ -44,25 +44,16 @@ describe('interaction-mode', () => { ).toEqual({ mode: 'agent', source: 'flag' }); }); - it('WORKOS_MODE beats WORKOS_NO_PROMPT', () => { + it('WORKOS_MODE beats env-based detection', () => { expect( resolveInteractionMode({ - env: { WORKOS_MODE: 'human', WORKOS_NO_PROMPT: '1' }, + env: { WORKOS_MODE: 'human', CI: '1' }, stdoutIsTTY: false, stderrIsTTY: false, }), ).toEqual({ mode: 'human', source: 'env' }); }); - it('WORKOS_NO_PROMPT=true maps to agent compatibility mode', () => { - expect( - resolveInteractionMode({ env: { WORKOS_NO_PROMPT: 'true' }, stdoutIsTTY: true, stderrIsTTY: true }), - ).toEqual({ - mode: 'agent', - source: 'workos_no_prompt', - }); - }); - it('CI markers beat agent markers when no explicit mode is set', () => { expect( resolveInteractionMode({ diff --git a/src/utils/interaction-mode.ts b/src/utils/interaction-mode.ts index b983a17f..d9d78972 100644 --- a/src/utils/interaction-mode.ts +++ b/src/utils/interaction-mode.ts @@ -1,13 +1,6 @@ export type InteractionMode = 'human' | 'agent' | 'ci'; -export type InteractionModeSource = - | 'flag' - | 'env' - | 'workos_no_prompt' - | 'ci_env' - | 'agent_env' - | 'non_tty' - | 'default'; +export type InteractionModeSource = 'flag' | 'env' | 'ci_env' | 'agent_env' | 'non_tty' | 'default'; export interface InteractionModeInfo { mode: InteractionMode; @@ -99,10 +92,6 @@ export function resolveInteractionMode(options: ResolveInteractionModeOptions = return { mode: parseModeValue(env.WORKOS_MODE, 'env'), source: 'env' }; } - if (isTruthy(env.WORKOS_NO_PROMPT)) { - return { mode: 'agent', source: 'workos_no_prompt' }; - } - if (hasCiMarker(env)) { return { mode: 'ci', source: 'ci_env' }; } diff --git a/src/utils/mode-compatibility.spec.ts b/src/utils/mode-compatibility.spec.ts index e379659d..e71337dc 100644 --- a/src/utils/mode-compatibility.spec.ts +++ b/src/utils/mode-compatibility.spec.ts @@ -14,12 +14,11 @@ describe('mode compatibility matrix', () => { const originalEnv = process.env; const originalIsTTY = process.stdout.isTTY; const originalStderrIsTTY = process.stderr.isTTY; - const interactionEnvKeys = ['WORKOS_MODE', 'WORKOS_NO_PROMPT', 'CI', 'GITHUB_ACTIONS', 'WORKOS_AGENT'] as const; + const interactionEnvKeys = ['WORKOS_MODE', 'CI', 'GITHUB_ACTIONS', 'WORKOS_AGENT'] as const; beforeEach(() => { process.env = { ...originalEnv }; delete process.env.WORKOS_FORCE_TTY; - delete process.env.WORKOS_NO_PROMPT; delete process.env.WORKOS_MODE; delete process.env.CI; delete process.env.GITHUB_ACTIONS; @@ -47,7 +46,7 @@ describe('mode compatibility matrix', () => { setup: () => { argv?: string[]; jsonFlag?: boolean }; expectOutput: 'human' | 'json'; expectMode: 'human' | 'agent' | 'ci'; - expectSource: 'flag' | 'env' | 'workos_no_prompt' | 'ci_env' | 'agent_env' | 'non_tty' | 'default'; + expectSource: 'flag' | 'env' | 'ci_env' | 'agent_env' | 'non_tty' | 'default'; }; const rows: Row[] = [ @@ -74,16 +73,16 @@ describe('mode compatibility matrix', () => { expectSource: 'non_tty', }, { - name: 'WORKOS_NO_PROMPT=1 maps output to json and interaction to agent (legacy compatibility)', + name: 'WORKOS_MODE=agent on a TTY forces json output and agent interaction', setup: () => { Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); Object.defineProperty(process.stderr, 'isTTY', { value: true, writable: true }); - process.env.WORKOS_NO_PROMPT = '1'; + process.env.WORKOS_MODE = 'agent'; return {}; }, expectOutput: 'json', expectMode: 'agent', - expectSource: 'workos_no_prompt', + expectSource: 'env', }, { name: 'WORKOS_FORCE_TTY=1 forces human output but does not change interaction mode (non-TTY)', diff --git a/src/utils/output.spec.ts b/src/utils/output.spec.ts index 7abfcdf4..480b499d 100644 --- a/src/utils/output.spec.ts +++ b/src/utils/output.spec.ts @@ -20,7 +20,6 @@ describe('output', () => { beforeEach(() => { process.env = { ...originalEnv }; delete process.env.WORKOS_FORCE_TTY; - delete process.env.WORKOS_NO_PROMPT; setOutputMode('human'); }); @@ -45,12 +44,6 @@ describe('output', () => { expect(resolveOutputMode()).toBe('json'); }); - it('returns json when WORKOS_NO_PROMPT is set for legacy output compatibility', () => { - Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); - process.env.WORKOS_NO_PROMPT = '1'; - expect(resolveOutputMode()).toBe('json'); - }); - it('returns human when stdout is a TTY and no flags', () => { Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); expect(resolveOutputMode()).toBe('human'); diff --git a/src/utils/output.ts b/src/utils/output.ts index 843bd0fe..e5828d35 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -23,14 +23,15 @@ let currentMode: OutputMode = 'human'; * Priority: * 1. Explicit --json flag * 2. WORKOS_FORCE_TTY env var → human output compatibility - * 3. WORKOS_NO_PROMPT legacy compatibility → json - * 4. Non-TTY auto-detection → json - * 5. Default → human + * 3. Non-TTY auto-detection → json + * 4. Default → human + * + * Note: agent interaction mode (WORKOS_MODE=agent) also forces JSON output, + * applied separately via resolveEffectiveOutputMode(). */ export function resolveOutputMode(jsonFlag?: boolean): OutputMode { if (jsonFlag) return 'json'; if (process.env.WORKOS_FORCE_TTY === '1' || process.env.WORKOS_FORCE_TTY === 'true') return 'human'; - if (process.env.WORKOS_NO_PROMPT === '1' || process.env.WORKOS_NO_PROMPT === 'true') return 'json'; if (!process.stdout.isTTY) return 'json'; return 'human'; } diff --git a/src/utils/telemetry-sanitize.spec.ts b/src/utils/telemetry-sanitize.spec.ts index 699e8a64..72d5e9ec 100644 --- a/src/utils/telemetry-sanitize.spec.ts +++ b/src/utils/telemetry-sanitize.spec.ts @@ -35,7 +35,6 @@ vi.mock('uuid', () => ({ })); vi.mock('../lib/settings.js', () => ({ - getTelemetryUrl: () => 'https://api.workos.com/cli', getConfig: () => ({ nodeVersion: '>=18', logging: { debugMode: false }, @@ -50,6 +49,10 @@ vi.mock('../lib/settings.js', () => ({ getVersion: () => '0.0.0-test', })); +vi.mock('./urls.js', () => ({ + getTelemetryUrl: () => 'https://api.workos.com/cli', +})); + vi.mock('../lib/credentials.js', () => ({ getCredentials: vi.fn(), })); @@ -161,7 +164,6 @@ describe('Analytics: no PII or secrets in queued events', () => { }, })); vi.doMock('../lib/settings.js', () => ({ - getTelemetryUrl: () => 'https://api.workos.com/cli', getConfig: () => ({ nodeVersion: '>=18', logging: { debugMode: false }, @@ -175,6 +177,9 @@ describe('Analytics: no PII or secrets in queued events', () => { }), getVersion: () => '0.0.0-test', })); + vi.doMock('./urls.js', () => ({ + getTelemetryUrl: () => 'https://api.workos.com/cli', + })); vi.doMock('../lib/credentials.js', () => ({ getCredentials: vi.fn(), })); diff --git a/src/utils/urls.ts b/src/utils/urls.ts index 29eaf975..b363c8d1 100644 --- a/src/utils/urls.ts +++ b/src/utils/urls.ts @@ -1,11 +1,18 @@ -import { getLlmGatewayUrl } from '../lib/settings.js'; - /** - * Get URLs. Env vars override config defaults. + * WorkOS service endpoint resolution. Env vars override defaults. + * + * WORKOS_API_URL is the single base host; the LLM gateway and CLI telemetry + * endpoints are served under it and derived here. The trailing slash is + * normalized at the source so every derived path stays clean. */ -export const getWorkOSApiUrl = () => process.env.WORKOS_API_URL || 'https://api.workos.com'; +export const getWorkOSApiUrl = (): string => + (process.env.WORKOS_API_URL || 'https://api.workos.com').replace(/\/$/, ''); + +export const getWorkOSDashboardUrl = (): string => process.env.WORKOS_DASHBOARD_URL || 'https://dashboard.workos.com'; -export const getWorkOSDashboardUrl = () => process.env.WORKOS_DASHBOARD_URL || 'https://dashboard.workos.com'; +/** LLM gateway endpoint, served under the WorkOS API host. */ +export const getLlmGatewayUrl = (): string => `${getWorkOSApiUrl()}/llm-gateway`; -export const getLlmGatewayUrlFromHost = getLlmGatewayUrl; +/** CLI telemetry endpoint, served under the WorkOS API host. */ +export const getTelemetryUrl = (): string => `${getWorkOSApiUrl()}/cli`;