From b76670ced0138cbf0a5ff0127692529d870f6a7d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 23 Apr 2026 18:49:57 -0700 Subject: [PATCH 1/3] fix: improve installer auth recovery --- README.md | 6 +++--- src/lib/adapters/cli-adapter.spec.ts | 22 +++++++++++++++++++++ src/lib/adapters/cli-adapter.ts | 3 ++- src/lib/device-auth.ts | 18 +++++++++++++---- src/lib/resolve-install-credentials.spec.ts | 14 ++++++------- src/lib/resolve-install-credentials.ts | 8 ++++---- src/lib/run-with-core.ts | 4 +++- src/utils/command-invocation.spec.ts | 16 +++++++++++++++ src/utils/command-invocation.ts | 20 +++++++++++++++++++ 9 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 src/utils/command-invocation.spec.ts create mode 100644 src/utils/command-invocation.ts diff --git a/README.md b/README.md index e63b3f94..755fe04b 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,12 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources. ## Installation ```bash -# Run directly with npx (recommended) -npx workos +# Run the installer directly with npx (recommended) +npx workos@latest install # Or install globally npm install -g workos -workos +workos install ``` ## Features diff --git a/src/lib/adapters/cli-adapter.spec.ts b/src/lib/adapters/cli-adapter.spec.ts index f1729ba1..f7af41f5 100644 --- a/src/lib/adapters/cli-adapter.spec.ts +++ b/src/lib/adapters/cli-adapter.spec.ts @@ -244,5 +244,27 @@ describe('CLIAdapter', () => { expect(output).toContain('Something went wrong'); consoleSpy.mockRestore(); }); + + it('keeps npx in auth recovery hints when launched through npm exec', async () => { + const originalNpmCommand = process.env.npm_command; + process.env.npm_command = 'exec'; + + try { + await adapter.start(); + const clack = await import('../../utils/clack.js'); + + emitter.emit('error', { message: 'authentication failed', stack: undefined }); + + expect(clack.default.log.info).toHaveBeenCalledWith( + 'Try running: npx workos@latest auth logout && npx workos@latest install', + ); + } finally { + if (originalNpmCommand === undefined) { + delete process.env.npm_command; + } else { + process.env.npm_command = originalNpmCommand; + } + } + }); }); }); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 32cb856f..24a91104 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -5,6 +5,7 @@ import chalk from 'chalk'; import { getConfig } from '../settings.js'; import { ProgressTracker } from '../progress-tracker.js'; import { renderCompletionSummary } from '../../utils/summary-box.js'; +import { formatWorkOSCommand } from '../../utils/command-invocation.js'; /** * CLI adapter that renders wizard events via clack. @@ -427,7 +428,7 @@ export class CLIAdapter implements InstallerAdapter { // Add actionable hints for common errors if (message.includes('authentication') || message.includes('auth')) { - clack.log.info('Try running: workos auth logout && workos install'); + clack.log.info(`Try running: ${formatWorkOSCommand('auth logout')} && ${formatWorkOSCommand('install')}`); } if (message.includes('ENOENT') || message.includes('not found')) { clack.log.info('Ensure you are in a project directory'); diff --git a/src/lib/device-auth.ts b/src/lib/device-auth.ts index 3aad17c5..dde06b56 100644 --- a/src/lib/device-auth.ts +++ b/src/lib/device-auth.ts @@ -54,6 +54,8 @@ export class DeviceAuthError extends Error { } const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const POLL_REQUEST_TIMEOUT_MS = 30_000; const DEFAULT_SCOPES = ['openid', 'email', 'staging-environment:credentials:read', 'offline_access']; function sleep(ms: number): Promise { @@ -122,7 +124,7 @@ export async function pollForToken( ): Promise { const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const startTime = Date.now(); - let pollInterval = options.interval * 1000; + let pollInterval = (options.interval || DEFAULT_POLL_INTERVAL_SECONDS) * 1000; const tokenUrl = `${options.authkitDomain}/oauth2/token`; logInfo('[device-auth] Starting token polling, timeout:', timeoutMs); @@ -131,6 +133,8 @@ export async function pollForToken( options.onPoll?.(); let res: Response; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), POLL_REQUEST_TIMEOUT_MS); try { res = await fetch(tokenUrl, { method: 'POST', @@ -140,10 +144,16 @@ export async function pollForToken( device_code: deviceCode, client_id: options.clientId, }), + signal: controller.signal, }); - } catch { - logInfo('[device-auth] Token poll network error, retrying'); + } catch (error) { + logInfo( + '[device-auth] Token poll network error, retrying:', + error instanceof Error ? error.message : String(error), + ); continue; + } finally { + clearTimeout(timeout); } let data; @@ -178,7 +188,7 @@ export async function pollForToken( } logError('[device-auth] Authentication timed out'); - throw new DeviceAuthError('Authentication timed out after 5 minutes'); + throw new DeviceAuthError(`Authentication timed out after ${Math.round(timeoutMs / 1000)} seconds`); } function parseTokenResponse(data: TokenResponse): DeviceAuthResult { diff --git a/src/lib/resolve-install-credentials.spec.ts b/src/lib/resolve-install-credentials.spec.ts index de78c0b8..eb645483 100644 --- a/src/lib/resolve-install-credentials.spec.ts +++ b/src/lib/resolve-install-credentials.spec.ts @@ -9,9 +9,9 @@ vi.mock('./config-store.js', () => ({ })); // Mock credentials -const mockHasCredentials = vi.fn(); +const mockGetAccessToken = vi.fn(); vi.mock('./credentials.js', () => ({ - hasCredentials: () => mockHasCredentials(), + getAccessToken: () => mockGetAccessToken(), })); // Mock unclaimed-env-provision @@ -70,26 +70,26 @@ describe('resolveInstallCredentials', () => { expect(mockTryProvisionUnclaimedEnv).not.toHaveBeenCalled(); }); - it('returns without auth when active env has API key and OAuth credentials', async () => { + it('returns without auth when active env has API key and a valid OAuth token', async () => { mockGetActiveEnvironment.mockReturnValue({ type: 'sandbox', apiKey: 'sk_test_xxx', }); mockIsUnclaimedEnvironment.mockReturnValue(false); - mockHasCredentials.mockReturnValue(true); + mockGetAccessToken.mockReturnValue('access_token'); await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); expect(mockAuthenticate).not.toHaveBeenCalled(); }); - it('authenticates when active env has API key but no gateway auth', async () => { + it('authenticates when active env has API key but no valid gateway auth', async () => { mockGetActiveEnvironment.mockReturnValue({ type: 'sandbox', apiKey: 'sk_test_xxx', }); mockIsUnclaimedEnvironment.mockReturnValue(false); - mockHasCredentials.mockReturnValue(false); + mockGetAccessToken.mockReturnValue(null); await resolveInstallCredentials(undefined, undefined, undefined, mockAuthenticate); @@ -102,7 +102,7 @@ describe('resolveInstallCredentials', () => { apiKey: 'sk_test_xxx', }); mockIsUnclaimedEnvironment.mockReturnValue(false); - mockHasCredentials.mockReturnValue(false); + mockGetAccessToken.mockReturnValue(null); await resolveInstallCredentials(undefined, undefined, true, mockAuthenticate); diff --git a/src/lib/resolve-install-credentials.ts b/src/lib/resolve-install-credentials.ts index 13108e18..744ac975 100644 --- a/src/lib/resolve-install-credentials.ts +++ b/src/lib/resolve-install-credentials.ts @@ -21,7 +21,7 @@ export async function resolveInstallCredentials( try { const { getActiveEnvironment, isUnclaimedEnvironment } = await import('./config-store.js'); - const { hasCredentials } = await import('./credentials.js'); + const { getAccessToken } = await import('./credentials.js'); const activeEnv = getActiveEnvironment(); if (activeEnv?.apiKey) { @@ -30,11 +30,11 @@ export async function resolveInstallCredentials( // Unclaimed with claim token — claim token proxy will handle gateway return; } - if (hasCredentials()) { - // Has OAuth tokens — credential proxy will handle gateway + if (getAccessToken()) { + // Has a valid OAuth token — credential proxy will handle gateway. return; } - // Has API key but no gateway auth — need to log in + // Has API key but no valid gateway auth — refresh or log in. if (!skipAuth) await authenticate(); return; } diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 0e9893d8..f0b9b82a 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -51,6 +51,7 @@ import { autoConfigureWorkOSEnvironment } from './workos-management.js'; import { detectPort, getCallbackPath } from './port-detection.js'; import { writeEnvLocal } from './env-writer.js'; import { getRegistry } from './registry.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; async function runIntegrationInstallerFn(integration: Integration, options: InstallerOptions): Promise { const registry = await getRegistry(); @@ -240,7 +241,7 @@ export async function runWithCore(options: InstallerOptions): Promise { if (!token) { // This should rarely happen since bin.ts handles auth first // But keep as safety net for programmatic usage - throw new Error('Not authenticated. Run `workos auth login` first.'); + throw new Error(`Not authenticated. Run \`${formatWorkOSCommand('auth login')}\` first.`); } // Set telemetry from existing credentials @@ -387,6 +388,7 @@ export async function runWithCore(options: InstallerOptions): Promise { expiresAt: result.expiresAt, userId: result.userId, email: result.email, + refreshToken: result.refreshToken, }); return { result, deviceAuth }; diff --git a/src/utils/command-invocation.spec.ts b/src/utils/command-invocation.spec.ts new file mode 100644 index 00000000..e390b780 --- /dev/null +++ b/src/utils/command-invocation.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { formatWorkOSCommand, getWorkOSCommand } from './command-invocation.js'; + +describe('command invocation helpers', () => { + it('uses workos for regular global/local invocations', () => { + expect(getWorkOSCommand({})).toBe('workos'); + }); + + it('uses npx workos@latest when launched by npm exec', () => { + expect(getWorkOSCommand({ npm_command: 'exec' })).toBe('npx workos@latest'); + }); + + it('formats commands with the detected invocation', () => { + expect(formatWorkOSCommand('auth login', { npm_command: 'exec' })).toBe('npx workos@latest auth login'); + }); +}); diff --git a/src/utils/command-invocation.ts b/src/utils/command-invocation.ts new file mode 100644 index 00000000..a0b26dc6 --- /dev/null +++ b/src/utils/command-invocation.ts @@ -0,0 +1,20 @@ +/** + * Return the safest user-facing way to invoke this CLI. + * + * When the package is run through npm exec/npx, `workos ...` may resolve to an + * older global binary in the user's shell. Recovery hints should keep using npx. + */ +export function getWorkOSCommand(env: NodeJS.ProcessEnv = process.env): string { + const npmCommand = env.npm_command; + const npmExecPath = env.npm_execpath ?? ''; + const npmUserAgent = env.npm_config_user_agent ?? ''; + + const launchedByNpmExec = + npmCommand === 'exec' || npmExecPath.includes('npx-cli') || /\bnpx\//.test(npmUserAgent); + + return launchedByNpmExec ? 'npx workos@latest' : 'workos'; +} + +export function formatWorkOSCommand(args: string, env: NodeJS.ProcessEnv = process.env): string { + return `${getWorkOSCommand(env)} ${args}`; +} From 16d4fb3229cb8abac97e4d4512e5af68febc0832 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 23 Apr 2026 18:52:08 -0700 Subject: [PATCH 2/3] chore: formatting --- src/utils/command-invocation.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utils/command-invocation.ts b/src/utils/command-invocation.ts index a0b26dc6..90e2f032 100644 --- a/src/utils/command-invocation.ts +++ b/src/utils/command-invocation.ts @@ -9,8 +9,7 @@ export function getWorkOSCommand(env: NodeJS.ProcessEnv = process.env): string { const npmExecPath = env.npm_execpath ?? ''; const npmUserAgent = env.npm_config_user_agent ?? ''; - const launchedByNpmExec = - npmCommand === 'exec' || npmExecPath.includes('npx-cli') || /\bnpx\//.test(npmUserAgent); + const launchedByNpmExec = npmCommand === 'exec' || npmExecPath.includes('npx-cli') || /\bnpx\//.test(npmUserAgent); return launchedByNpmExec ? 'npx workos@latest' : 'workos'; } From d15d8a38dc09992cf714dcfa3d3321324f01e122 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 23 Apr 2026 19:03:18 -0700 Subject: [PATCH 3/3] fix: tighten auth recovery guidance --- README.md | 20 ++++++++++++-------- src/commands/auth-status.ts | 3 ++- src/commands/claim.ts | 7 ++++--- src/commands/login.ts | 5 +++-- src/doctor/checks/ai-analysis.ts | 8 +++++--- src/lib/agent-interface.ts | 11 ++++++----- src/lib/credential-proxy.ts | 3 ++- src/lib/device-auth.ts | 26 ++++++++++++++++++-------- src/lib/ensure-auth.ts | 9 ++++++--- src/lib/token-refresh-client.ts | 3 ++- src/lib/token-refresh.ts | 5 +++-- src/utils/exit-codes.ts | 5 ++++- 12 files changed, 67 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 755fe04b..ebc5b9f4 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ npm install -g workos workos install ``` +`npx workos@latest install` is recommended because it bypasses stale global shims and older shell-resolved binaries. +If a global install reports `unknown command "install"`, run the npx command above or reinstall globally and clear your +shell command cache. + ## Features - **15 Framework Support:** Next.js, React Router, TanStack Start, React SPA, Vanilla JS, SvelteKit, Node.js (Express), Python (Django), Ruby (Rails), Go, .NET (ASP.NET Core), Kotlin (Spring Boot), Elixir (Phoenix), PHP (Laravel), PHP @@ -93,7 +97,7 @@ When you run `workos install` without credentials, the CLI automatically provisi ```bash # Install with zero setup — environment provisioned automatically -workos install +npx workos@latest install # Check your environment workos env list @@ -560,13 +564,13 @@ workos install [options] ```bash # Interactive (recommended) -npx workos +npx workos@latest install # Specify framework -npx workos install --integration react-router +npx workos@latest install --integration react-router # With visual dashboard (experimental) -npx workos dashboard +npx workos@latest dashboard # JSON output (explicit) workos org list --json --api-key sk_test_xxx @@ -648,13 +652,13 @@ The CLI uses WorkOS Connect OAuth device flow for authentication: ```bash # Login (opens browser for authentication) -workos auth login +npx workos@latest auth login # Check current auth status -workos auth status +npx workos@latest auth status # Logout (clears stored credentials) -workos auth logout +npx workos@latest auth logout ``` OAuth credentials are stored in the system keychain (with `~/.workos/credentials.json` fallback). Access tokens are not persisted long-term for security - users re-authenticate when tokens expire. @@ -682,7 +686,7 @@ The installer collects anonymous usage telemetry to help improve the product: No code, credentials, or personal data is collected. Disable with: ```bash -WORKOS_TELEMETRY=false npx workos +WORKOS_TELEMETRY=false npx workos@latest install ``` ## Logs diff --git a/src/commands/auth-status.ts b/src/commands/auth-status.ts index c59e03b2..d4ad307c 100644 --- a/src/commands/auth-status.ts +++ b/src/commands/auth-status.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { getCredentials, isTokenExpired } from '../lib/credentials.js'; import { getActiveEnvironment } from '../lib/config-store.js'; import { isJsonMode, outputJson } from '../utils/output.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; function formatTimeRemaining(ms: number): string { if (ms <= 0) return 'expired'; @@ -23,7 +24,7 @@ export async function runAuthStatus(): Promise { return; } console.log(chalk.yellow('Not logged in')); - console.log(chalk.dim('Run `workos auth login` to authenticate')); + console.log(chalk.dim(`Run \`${formatWorkOSCommand('auth login')}\` to authenticate`)); return; } diff --git a/src/commands/claim.ts b/src/commands/claim.ts index 7ebe25fa..30f36c3c 100644 --- a/src/commands/claim.ts +++ b/src/commands/claim.ts @@ -13,6 +13,7 @@ import { createClaimNonce, UnclaimedEnvApiError } from '../lib/unclaimed-env-api import { logInfo, logError } from '../utils/debug.js'; import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; import { sleep } from '../lib/helper-functions.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const POLL_INTERVAL_MS = 5_000; // 5 seconds @@ -48,7 +49,7 @@ export async function runClaim(): Promise { outputJson({ status: 'already_claimed', message: 'Environment already claimed!' }); } else { clack.log.success('Environment already claimed!'); - clack.log.info('Run `workos auth login` to connect your account.'); + clack.log.info(`Run \`${formatWorkOSCommand('auth login')}\` to connect your account.`); } return; } @@ -84,7 +85,7 @@ export async function runClaim(): Promise { if (check.alreadyClaimed) { spinner.stop('Environment claimed!'); markEnvironmentClaimed(); - clack.log.info('Run `workos auth login` to connect your account.'); + clack.log.info(`Run \`${formatWorkOSCommand('auth login')}\` to connect your account.`); return; } consecutiveFailures = 0; @@ -95,7 +96,7 @@ export async function runClaim(): Promise { // when the environment is claimed. Safe to promote to sandbox. spinner.stop('Claim token is invalid or expired.'); markEnvironmentClaimed(); - clack.log.warn('Run `workos auth login` to set up your environment.'); + clack.log.warn(`Run \`${formatWorkOSCommand('auth login')}\` to set up your environment.`); return; } consecutiveFailures++; diff --git a/src/commands/login.ts b/src/commands/login.ts index 0fa7a60d..ca033d61 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -8,6 +8,7 @@ import { logInfo, logError } from '../utils/debug.js'; import { fetchStagingCredentials } from '../lib/staging-api.js'; import { getConfig, saveConfig } from '../lib/config-store.js'; import type { CliConfig } from '../lib/config-store.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; /** * Parse JWT payload @@ -110,7 +111,7 @@ export async function runLogin(): Promise { if (getAccessToken()) { const creds = getCredentials(); console.log(chalk.green(`Already logged in as ${creds?.email ?? 'unknown'}`)); - console.log(chalk.dim('Run `workos auth logout` to log out')); + console.log(chalk.dim(`Run \`${formatWorkOSCommand('auth logout')}\` to log out`)); return; } @@ -124,7 +125,7 @@ export async function runLogin(): Promise { updateTokens(result.accessToken, result.expiresAt, result.refreshToken); logInfo('[login] Session refreshed via refresh token'); console.log(chalk.green(`Already logged in as ${existingCreds.email ?? 'unknown'}`)); - console.log(chalk.dim('Run `workos auth logout` to log out')); + console.log(chalk.dim(`Run \`${formatWorkOSCommand('auth logout')}\` to log out`)); return; } } catch { diff --git a/src/doctor/checks/ai-analysis.ts b/src/doctor/checks/ai-analysis.ts index 0b25b391..90d2e1e5 100644 --- a/src/doctor/checks/ai-analysis.ts +++ b/src/doctor/checks/ai-analysis.ts @@ -4,6 +4,7 @@ import { getCredentials, isTokenExpired, updateTokens, diagnoseCredentials } fro import { refreshAccessToken } from '../../lib/token-refresh-client.js'; import { buildDoctorPrompt, type AnalysisContext } from '../agent-prompt.js'; import type { AiAnalysis, AiFinding } from '../types.js'; +import { formatWorkOSCommand } from '../../utils/command-invocation.js'; const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -61,10 +62,11 @@ async function callModel(prompt: string, model: string): Promise { if (!creds) throw new Error('Not authenticated'); if (isTokenExpired(creds)) { - if (!creds.refreshToken) throw new Error('Session expired — run `workos auth login` to re-authenticate'); + if (!creds.refreshToken) + throw new Error(`Session expired — run \`${formatWorkOSCommand('auth login')}\` to re-authenticate`); const result = await refreshAccessToken(getAuthkitDomain(), getCliAuthClientId()); if (!result.success || !result.accessToken || !result.expiresAt) { - throw new Error('Session expired — run `workos auth login` to re-authenticate'); + throw new Error(`Session expired — run \`${formatWorkOSCommand('auth login')}\` to re-authenticate`); } updateTokens(result.accessToken, result.expiresAt, result.refreshToken); creds = getCredentials()!; @@ -111,7 +113,7 @@ export async function checkAiAnalysis(context: AnalysisContext, options: { skipA process.stderr.write(` ${line}\n`); } process.stderr.write('\n'); - return skippedResult('Not authenticated — run `workos auth login` for AI-powered analysis'); + return skippedResult(`Not authenticated — run \`${formatWorkOSCommand('auth login')}\` for AI-powered analysis`); } const startTime = Date.now(); diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 090a2538..eba4a581 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -11,6 +11,7 @@ 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 { getCredentials, hasCredentials } from './credentials.js'; import { ensureValidToken } from './token-refresh.js'; @@ -395,12 +396,12 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt } else if (!options.skipAuth && !options.local) { // Check/refresh authentication for production (unless skipping auth) if (!hasCredentials()) { - throw new Error('Not authenticated. Run `workos auth login` to authenticate.'); + throw new Error(`Not authenticated. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); } const creds = getCredentials(); if (!creds) { - throw new Error('Not authenticated. Run `workos auth login` to authenticate.'); + throw new Error(`Not authenticated. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); } // Check if we have refresh token capability and proxy is not disabled @@ -421,7 +422,7 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt onRefreshExpired: () => { logError('[agent-interface] Session expired, refresh token invalid'); options.emitter?.emit('error', { - message: 'Session expired. Run `workos auth login` to re-authenticate.', + message: `Session expired. Run \`${formatWorkOSCommand('auth login')}\` to re-authenticate.`, }); }, }, @@ -441,9 +442,9 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt // No refresh token OR proxy disabled - fall back to old behavior (5 min limit) if (!creds.refreshToken) { logWarn('[agent-interface] No refresh token available, session limited to 5 minutes'); - logWarn('[agent-interface] Run `workos auth login` to enable extended sessions'); + logWarn(`[agent-interface] Run \`${formatWorkOSCommand('auth login')}\` to enable extended sessions`); options.emitter?.emit('status', { - message: 'Note: Run `workos auth login` to enable extended sessions', + message: `Note: Run \`${formatWorkOSCommand('auth login')}\` to enable extended sessions`, }); } else { logWarn('[agent-interface] Proxy disabled via INSTALLER_DISABLE_PROXY'); diff --git a/src/lib/credential-proxy.ts b/src/lib/credential-proxy.ts index 68f26d78..480a617c 100644 --- a/src/lib/credential-proxy.ts +++ b/src/lib/credential-proxy.ts @@ -10,6 +10,7 @@ import { logInfo, logError, logWarn } from '../utils/debug.js'; import { getCredentials, updateTokens, type Credentials } from './credentials.js'; import { analytics } from '../utils/analytics.js'; import { refreshAccessToken } from './token-refresh-client.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface RefreshConfig { /** AuthKit domain for refresh endpoint */ @@ -286,7 +287,7 @@ async function handleRequest( res.end( JSON.stringify({ error: 'credentials_unavailable', - message: 'Not authenticated. Run `workos auth login` first.', + message: `Not authenticated. Run \`${formatWorkOSCommand('auth login')}\` first.`, }), ); return; diff --git a/src/lib/device-auth.ts b/src/lib/device-auth.ts index dde06b56..df296a47 100644 --- a/src/lib/device-auth.ts +++ b/src/lib/device-auth.ts @@ -44,6 +44,7 @@ interface TokenResponse { interface AuthErrorResponse { error: string; + error_description?: string; } export class DeviceAuthError extends Error { @@ -126,10 +127,13 @@ export async function pollForToken( const startTime = Date.now(); let pollInterval = (options.interval || DEFAULT_POLL_INTERVAL_SECONDS) * 1000; const tokenUrl = `${options.authkitDomain}/oauth2/token`; + let pollCount = 0; + let lastPollSummary = 'no token response received'; logInfo('[device-auth] Starting token polling, timeout:', timeoutMs); while (Date.now() - startTime < timeoutMs) { await sleep(pollInterval); + pollCount++; options.onPoll?.(); let res: Response; @@ -159,19 +163,23 @@ export async function pollForToken( let data; try { data = await res.json(); - } catch { - logError('[device-auth] Invalid JSON response from auth server'); - throw new DeviceAuthError('Invalid response from auth server'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logError('[device-auth] Invalid JSON response from auth server:', message); + throw new DeviceAuthError(`Invalid response from auth server: ${message}`); } - logInfo('[device-auth] Token poll response:', res.status, (data as AuthErrorResponse)?.error ?? 'success'); + const errorData = data as AuthErrorResponse; + const elapsedMs = Date.now() - startTime; + lastPollSummary = res.ok + ? `${res.status} success` + : `${res.status} ${errorData.error ?? 'unknown_error'}${errorData.error_description ? ` (${errorData.error_description})` : ''}`; + logInfo('[device-auth] Token poll response:', `attempt=${pollCount}`, `elapsedMs=${elapsedMs}`, lastPollSummary); if (res.ok) { logInfo('[device-auth] Token received successfully'); return parseTokenResponse(data as TokenResponse); } - const errorData = data as AuthErrorResponse; - if (errorData.error === 'authorization_pending') { continue; } @@ -187,8 +195,10 @@ export async function pollForToken( throw new DeviceAuthError(`Token error: ${errorData.error}`); } - logError('[device-auth] Authentication timed out'); - throw new DeviceAuthError(`Authentication timed out after ${Math.round(timeoutMs / 1000)} seconds`); + logError('[device-auth] Authentication timed out, last poll:', lastPollSummary); + throw new DeviceAuthError( + `Authentication timed out after ${Math.round(timeoutMs / 1000)} seconds (last token response: ${lastPollSummary})`, + ); } function parseTokenResponse(data: TokenResponse): DeviceAuthResult { diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index ba47ba8b..5d5f3086 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -9,6 +9,7 @@ import { runLogin } from '../commands/login.js'; import { logInfo } from '../utils/debug.js'; import { isNonInteractiveEnvironment } from '../utils/environment.js'; import { exitWithAuthRequired } from '../utils/exit-codes.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface EnsureAuthResult { /** Whether auth is now valid */ @@ -78,7 +79,7 @@ export async function ensureAuthenticated(): Promise { clearCredentials(); if (isNonInteractiveEnvironment()) { exitWithAuthRequired( - 'Session expired. Run `workos auth login` in an interactive terminal to re-authenticate.', + `Session expired. Run \`${formatWorkOSCommand('auth login')}\` in an interactive terminal to re-authenticate.`, ); } logInfo('[ensure-auth] Refresh token expired, triggering login'); @@ -91,7 +92,7 @@ export async function ensureAuthenticated(): Promise { // Network or server error - keep credentials intact for retry if (isNonInteractiveEnvironment()) { exitWithAuthRequired( - `Authentication refresh failed (${refreshResult.errorType}). Run \`workos auth login\` in an interactive terminal.`, + `Authentication refresh failed (${refreshResult.errorType}). Run \`${formatWorkOSCommand('auth login')}\` in an interactive terminal.`, ); } logInfo(`[ensure-auth] Refresh failed (${refreshResult.errorType}), triggering login`); @@ -105,7 +106,9 @@ export async function ensureAuthenticated(): Promise { // Case 4: No refresh token available — clear stale creds, must login clearCredentials(); if (isNonInteractiveEnvironment()) { - exitWithAuthRequired('Session expired. Run `workos auth login` in an interactive terminal to re-authenticate.'); + exitWithAuthRequired( + `Session expired. Run \`${formatWorkOSCommand('auth login')}\` in an interactive terminal to re-authenticate.`, + ); } logInfo('[ensure-auth] No refresh token, triggering login'); await runLogin(); diff --git a/src/lib/token-refresh-client.ts b/src/lib/token-refresh-client.ts index 7952a072..b9c58267 100644 --- a/src/lib/token-refresh-client.ts +++ b/src/lib/token-refresh-client.ts @@ -4,6 +4,7 @@ import { logInfo, logError } from '../utils/debug.js'; import { getCredentials } from './credentials.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface RefreshResult { success: boolean; @@ -73,7 +74,7 @@ export async function refreshAccessToken(authkitDomain: string, clientId: string if (errorData.error === 'invalid_grant') { return { success: false, - error: 'Session expired. Run `workos auth login` to re-authenticate.', + error: `Session expired. Run \`${formatWorkOSCommand('auth login')}\` to re-authenticate.`, errorType: 'invalid_grant', }; } diff --git a/src/lib/token-refresh.ts b/src/lib/token-refresh.ts index 84306074..b6be47bc 100644 --- a/src/lib/token-refresh.ts +++ b/src/lib/token-refresh.ts @@ -1,5 +1,6 @@ import { getCredentials, isTokenExpired, Credentials } from './credentials.js'; import { logInfo } from '../utils/debug.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; export interface TokenValidationResult { success: boolean; @@ -10,7 +11,7 @@ export interface TokenValidationResult { /** * Check if the current token is valid. * If expired, returns an error prompting re-authentication. - * No refresh is attempted - refresh tokens are not stored for security. + * No refresh is attempted here; callers decide whether to refresh or re-authenticate. */ export async function ensureValidToken(): Promise { const creds = getCredentials(); @@ -27,7 +28,7 @@ export async function ensureValidToken(): Promise { logInfo('[ensureValidToken] Token expired, re-authentication required'); return { success: false, - error: 'Session expired. Run `workos auth login` to re-authenticate.', + error: `Session expired. Run \`${formatWorkOSCommand('auth login')}\` to re-authenticate.`, }; } diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts index ab77f8fd..cbd49bd6 100644 --- a/src/utils/exit-codes.ts +++ b/src/utils/exit-codes.ts @@ -8,6 +8,7 @@ */ import { outputError } from './output.js'; +import { formatWorkOSCommand } from './command-invocation.js'; export const ExitCode = { SUCCESS: 0, @@ -30,6 +31,8 @@ export function exitWithCode(code: ExitCodeValue, error?: { code: string; messag export function exitWithAuthRequired(message?: string): never { exitWithCode(ExitCode.AUTH_REQUIRED, { code: 'auth_required', - message: message ?? 'Not authenticated. Run `workos auth login` in an interactive terminal, or set WORKOS_API_KEY.', + message: + message ?? + `Not authenticated. Run \`${formatWorkOSCommand('auth login')}\` in an interactive terminal, or set WORKOS_API_KEY.`, }); }