Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

`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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/commands/auth-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,7 +24,7 @@ export async function runAuthStatus(): Promise<void> {
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;
}

Expand Down
7 changes: 4 additions & 3 deletions src/commands/claim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,7 +49,7 @@ export async function runClaim(): Promise<void> {
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;
}
Expand Down Expand Up @@ -84,7 +85,7 @@ export async function runClaim(): Promise<void> {
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;
Expand All @@ -95,7 +96,7 @@ export async function runClaim(): Promise<void> {
// 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++;
Expand Down
5 changes: 3 additions & 2 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -110,7 +111,7 @@ export async function runLogin(): Promise<void> {
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;
}

Expand All @@ -124,7 +125,7 @@ export async function runLogin(): Promise<void> {
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 {
Expand Down
8 changes: 5 additions & 3 deletions src/doctor/checks/ai-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];

Expand Down Expand Up @@ -61,10 +62,11 @@ async function callModel(prompt: string, model: string): Promise<string> {
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()!;
Expand Down Expand Up @@ -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();
Expand Down
22 changes: 22 additions & 0 deletions src/lib/adapters/cli-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
});
});
});
3 changes: 2 additions & 1 deletion src/lib/adapters/cli-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
Expand Down
11 changes: 6 additions & 5 deletions src/lib/agent-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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.`,
});
},
},
Expand All @@ -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');
Expand Down
3 changes: 2 additions & 1 deletion src/lib/credential-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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;
Expand Down
42 changes: 31 additions & 11 deletions src/lib/device-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface TokenResponse {

interface AuthErrorResponse {
error: string;
error_description?: string;
}

export class DeviceAuthError extends Error {
Expand All @@ -54,6 +55,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<void> {
Expand Down Expand Up @@ -122,15 +125,20 @@ export async function pollForToken(
): Promise<DeviceAuthResult> {
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`;
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;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), POLL_REQUEST_TIMEOUT_MS);
try {
res = await fetch(tokenUrl, {
method: 'POST',
Expand All @@ -140,28 +148,38 @@ 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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} finally {
clearTimeout(timeout);
}

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;
}
Expand All @@ -177,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 5 minutes');
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 {
Expand Down
Loading
Loading