Skip to content
Open
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
31 changes: 30 additions & 1 deletion src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,20 @@ const insecureStorageOption = {
},
} as const;

/**
* Shared override for the "AuthKit is already installed" preflight guard.
*
* Distinct from `--force-install` below, which only relaxes peer-dependency
* checks during package installation.
*/
const forceOption = {
force: {
default: false,
describe: 'Continue even if AuthKit is already installed in this project',
type: 'boolean' as const,
},
} as const;

const installerOptions = {
direct: {
alias: 'D',
Expand Down Expand Up @@ -251,6 +265,7 @@ const installerOptions = {
describe: 'Next.js router to target when detection is ambiguous (app or pages)',
type: 'string' as const,
},
...forceOption,
};

// Check for updates (blocks up to 500ms, skip in JSON/non-human modes to keep machine streams clean)
Expand Down Expand Up @@ -2551,6 +2566,11 @@ async function runCli(): Promise<void> {
(yargs) => yargs.options(installerOptions),
async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
// MUST run before credential resolution below: that provisions a WorkOS
// environment and writes its credentials into the project's env file,
// so a guard placed after it is no guard at all.
const preflight = await import('./lib/preflight-authkit.js');
await preflight.assertNoExistingAuthKit({ installDir: argv.installDir ?? process.cwd(), force: argv.force });
await resolveInstallCredentials(argv.apiKey, argv.installDir, argv.skipAuth, ensureAuthenticated);
const { handleInstall } = await import('./commands/install.js');
await handleInstall(argv);
Expand Down Expand Up @@ -2750,6 +2770,9 @@ async function runCli(): Promise<void> {
(yargs) => yargs.options(installerOptions),
async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
// Guard first, before credential resolution — see the `install` handler above.
const preflight = await import('./lib/preflight-authkit.js');
await preflight.assertNoExistingAuthKit({ installDir: argv.installDir ?? process.cwd(), force: argv.force });
await resolveInstallCredentials(argv.apiKey, argv.installDir, argv.skipAuth, ensureAuthenticated);
const { handleInstall } = await import('./commands/install.js');
await handleInstall({ ...argv, dashboard: true });
Expand All @@ -2758,7 +2781,9 @@ async function runCli(): Promise<void> {
.command(
['$0'],
'WorkOS AuthKit CLI',
(yargs) => yargs.options(insecureStorageOption),
// `--force` must be registered here too: this parser is .strict(), so
// `npx workos --force` would die as an unknown argument otherwise.
(yargs) => yargs.options({ ...insecureStorageOption, ...forceOption }),
async (argv) => {
// Non-human modes: emit machine-readable command tree (JSON) or the
// fully-configured parser help (human non-TTY edge) instead of prompting.
Expand All @@ -2782,6 +2807,10 @@ async function runCli(): Promise<void> {
}

await applyInsecureStorage(argv.insecureStorage);
// After the confirm above (two prompts back to back is worse UX), but
// still before credential resolution touches the project.
const preflight = await import('./lib/preflight-authkit.js');
await preflight.assertNoExistingAuthKit({ installDir: process.cwd(), force: argv.force });
await resolveInstallCredentials(undefined, undefined, false, ensureAuthenticated);

const { handleInstall } = await import('./commands/install.js');
Expand Down
8 changes: 7 additions & 1 deletion src/doctor/checks/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ const SDK_PACKAGES = [
'workos', // very old legacy
] as const;

const AUTHKIT_PACKAGES = new Set([
/**
* AuthKit SDKs only — deliberately excludes `@workos-inc/node` and legacy
* `workos`, which are present in plenty of projects that never installed
* AuthKit. Consumed by the install preflight guard (`lib/preflight-authkit.ts`),
* where matching the base SDK would block legitimate first-time installs.
*/
export const AUTHKIT_PACKAGES = new Set([
'@workos/authkit-tanstack-react-start',
'@workos/authkit-sveltekit',
'@workos-inc/authkit-nextjs',
Expand Down
56 changes: 56 additions & 0 deletions src/lib/adapters/cli-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,62 @@ describe('CLIAdapter', () => {
});
});

describe('error rendering', () => {
async function renderError(message: string): Promise<string> {
await adapter.start();
const ui = await import('../../utils/ui.js');

emitter.emit('error', { message, stack: undefined });

return [...vi.mocked(ui.default.log.error).mock.calls, ...vi.mocked(ui.default.log.info).mock.calls]
.map((c) => String(c[0]))
.join('\n');
}

// The gateway's generic 500 fires for deterministic request failures too, so
// this is the interactive copy the reporter hit: four more minutes, same 500.
it('does not advise waiting for the gateway generic 500', async () => {
const output = await renderError(
'API Error: 500 {"error":{"type":"internal_error","message":"An unexpected error occurred"}}',
);

expect(output).not.toMatch(/temporarily unavailable/i);
expect(output).not.toMatch(/few minutes|try again shortly|wait a minute/i);
expect(output).toMatch(/could not complete this request/i);
});

// installer-core re-emits runAgent's already-rendered message, so the
// adapter classifies our own copy on the real path — not the raw SDK text.
it('keeps the deterministic copy when handed an already-rendered message', async () => {
const output = await renderError(
'The AI service could not complete this request. The same request is likely to fail the same way, so waiting will not help. Re-run with --debug to see the underlying error.',
);

expect(output).not.toMatch(/temporarily unavailable/i);

// The headline must be re-derived, not echoed: a pass-through of the
// already-rendered string would put all three sentences in log.error.
const ui = await import('../../utils/ui.js');
expect(vi.mocked(ui.default.log.error).mock.calls[0]?.[0]).toBe(
'The AI service could not complete this request.',
);
});

it('still shows the transient copy for a real 503', async () => {
const output = await renderError('API Error: 503 Service Unavailable');

expect(output).toMatch(/temporarily unavailable/i);
expect(output).toMatch(/few minutes/i);
});

it('renders the raw message when nothing matches', async () => {
const output = await renderError('Something broke');

expect(output).toMatch(/Something broke/);
expect(output).toMatch(/--debug/);
});
});

describe('staging success copy', () => {
it('device path announces a fresh environment without "retrieved"', async () => {
await adapter.start();
Expand Down
45 changes: 10 additions & 35 deletions src/lib/adapters/cli-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import chalk from 'chalk';
import { getConfig } from '../settings.js';
import { ProgressTracker } from '../progress-tracker.js';
import { renderCompletionSummary, renderBrandMark } from '../../utils/summary-box.js';
import { formatWorkOSCommand } from '../../utils/command-invocation.js';
import { classifyAgentFailure, describeAgentFailure } from '../failure-classifier.js';

/**
* CLI adapter that renders wizard events via ui.
Expand Down Expand Up @@ -548,41 +548,16 @@ export class CLIAdapter implements InstallerAdapter {
this.stopSpinner('Failed', 1);

// Rewrite raw API/SDK errors into user-friendly messages with a next step.
// Matching is word-boundary / code-based so 'author' doesn't read as 'auth'
// and 'Module not found' doesn't read as a missing-directory error.
const isServiceError =
/\b50[0-9]\b/.test(message) || /server_error|internal_error|overloaded|service.*unavailable/i.test(message);
const isRateLimit = /\b429\b/.test(message) || /\brate.?limit/i.test(message);
const isNetworkError = /ECONNREFUSED|ETIMEDOUT|ENOTFOUND|fetch failed/i.test(message);
const isProcessExit = /process exited with code/i.test(message);
const isAuthError = /\b(401|403|unauthorized|forbidden|authentication|authorization)\b/i.test(message);
const isMissingPath = /\bENOENT\b/.test(message);

if (isServiceError) {
ui.log.error('The AI service is temporarily unavailable.');
ui.log.info('This is usually resolved within a few minutes. Please try again shortly.');
} else if (isRateLimit) {
ui.log.error('The AI service is currently rate-limited.');
ui.log.info('Please wait a minute and try again.');
} else if (isNetworkError) {
ui.log.error('Could not connect to the AI service.');
ui.log.info('Check your internet connection and try again.');
} else if (isProcessExit) {
ui.log.error('The AI agent process exited unexpectedly.');
ui.log.info('Try running again. If this persists, run with --debug for details.');
} else if (isAuthError) {
ui.log.error('Authentication failed.');
ui.log.info(`Try running: ${formatWorkOSCommand('auth logout')} && ${formatWorkOSCommand('install')}`);
} else if (isMissingPath) {
ui.log.error(message);
ui.log.info('Make sure you are running this in your project directory.');
} else {
// Unknown error: still give the user somewhere to go next.
ui.log.error(message);
ui.log.info(
// The verdict comes from the shared classifier so this path cannot drift
// from the headless adapter or from runAgent's own error message.
const { headline, detail } = describeAgentFailure(classifyAgentFailure(message));

ui.log.error(headline ?? message);
ui.log.info(
// No specific advice for this kind: still give the user somewhere to go next.
detail ??
`Re-run with ${chalk.cyan('--debug')} for details, or report it at ${chalk.cyan('https://github.com/workos/cli/issues')}`,
);
}
);

if (stack && this.debug) {
this.debugLog(stack);
Expand Down
33 changes: 33 additions & 0 deletions src/lib/adapters/headless-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,39 @@ describe('HeadlessAdapter', () => {
});
await adapter.stop();
});

// The gateway's generic 500 branch also fires for request-shape failures, so
// agents and CI must not be told to wait it out: the retry fails identically.
it('codes the gateway generic 500 as deterministic, not a transient outage', async () => {
const adapter = createAdapter();
await adapter.start();

emitter.emit('error', { message: 'API Error: 500 An unexpected error occurred', stack: undefined });

expect(mockWriteNDJSON).toHaveBeenCalledWith({
type: 'error',
code: 'deterministic_error',
message: expect.stringContaining('likely to fail the same way'),
});
expect(mockWriteNDJSON).not.toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('temporarily unavailable') }),
);
await adapter.stop();
});

it('still codes a real 503 as service_unavailable', async () => {
const adapter = createAdapter();
await adapter.start();

emitter.emit('error', { message: 'API Error: 503 Service Unavailable', stack: undefined });

expect(mockWriteNDJSON).toHaveBeenCalledWith({
type: 'error',
code: 'service_unavailable',
message: expect.stringContaining('temporarily unavailable'),
});
await adapter.stop();
});
});

describe('staging events', () => {
Expand Down
48 changes: 25 additions & 23 deletions src/lib/adapters/headless-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@ import type { InstallerAdapter, AdapterConfig } from './types.js';
import type { InstallerEventEmitter, InstallerEvents } from '../events.js';
import { writeNDJSON } from '../../utils/ndjson.js';
import { ExitCode } from '../../utils/exit-codes.js';
import { classifyAgentFailure, formatAgentFailure, type AgentFailureKind } from '../failure-classifier.js';

/**
* Structured `code` emitted per failure kind. These are part of the NDJSON
* contract, so the existing codes must not be renamed; kinds without a
* dedicated code stay on `installer_error` and stream the raw message.
*/
const ERROR_CODES: Partial<Record<AgentFailureKind, string>> = {
service_outage: 'service_unavailable',
rate_limited: 'rate_limited',
deterministic: 'deterministic_error',
network: 'network_error',
process_exit: 'process_error',
};

/**
* Options controlling headless adapter behavior.
Expand Down Expand Up @@ -412,30 +426,18 @@ export class HeadlessAdapter implements InstallerAdapter {
};

private handleError = ({ message, stack }: InstallerEvents['error']): void => {
const isServiceError =
/\b50[0-9]\b/.test(message) || /server_error|internal_error|overloaded|service.*unavailable/i.test(message);
const isRateLimit = /\b429\b/.test(message) || /rate.limit/i.test(message);
const isNetworkError = /ECONNREFUSED|ETIMEDOUT|ENOTFOUND|fetch failed/i.test(message);
const isProcessExit = /process exited with code/i.test(message);

let code = 'installer_error';
let displayMessage = message;

if (isServiceError) {
code = 'service_unavailable';
displayMessage = 'The AI service is temporarily unavailable. Please try again in a few minutes.';
} else if (isRateLimit) {
code = 'rate_limited';
displayMessage = 'The AI service is currently rate-limited. Please wait a minute and try again.';
} else if (isNetworkError) {
code = 'network_error';
displayMessage = 'Could not connect to the AI service. Check your internet connection and try again.';
} else if (isProcessExit) {
code = 'process_error';
displayMessage = 'The AI agent process exited unexpectedly. Try running again with --debug for details.';
}
// The verdict comes from the shared classifier so JSON consumers and the
// interactive path cannot disagree about whether a retry is worth trying.
const kind = classifyAgentFailure(message);
const code = ERROR_CODES[kind];

writeNDJSON({ type: 'error', code, message: displayMessage });
writeNDJSON({
type: 'error',
code: code ?? 'installer_error',
// Kinds with no dedicated code keep streaming the raw message — JSON
// consumers depend on it where we have nothing better to say.
message: code ? formatAgentFailure(kind, message) : message,
});
this.debugLog(stack ?? '');
};
}
40 changes: 37 additions & 3 deletions src/lib/agent-interface.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,12 +300,47 @@ describe('service unavailability handling', () => {
}) as typeof emitter.emit;
});

it('detects is_error result with API 500 as SERVICE_UNAVAILABLE', async () => {
// The gateway's generic 500 branch also fires for request-shape failures, so
// this text is NOT evidence of an outage. Telling the user to wait a few
// minutes costs them another four-minute run that fails identically.
it('treats the gateway generic 500 as deterministic, not a transient outage', async () => {
const apiErrorText = 'API Error: 500 {"error":{"type":"internal_error","message":"An unexpected error occurred"}}';
mockQuery.mockImplementation(createMockSDKResponse([{ text: apiErrorText, is_error: true }]));

const result = await runAgent(makeAgentConfig(), 'Test prompt', makeOptions(), undefined, emitter);

expect(result.error).toBe(AgentErrorType.EXECUTION_ERROR);
expect(result.errorMessage).not.toMatch(/temporarily unavailable/);
expect(result.errorMessage).not.toMatch(/few minutes|try again shortly/i);
expect(result.errorMessage).toMatch(/likely to fail the same way/);
});

// The proxy answers its own socket timeout with a 504, so the message carries a
// 5xx that the old "any 5xx is transient" rule misread as an outage.
it("treats the proxy's own upstream_timeout as deterministic", async () => {
mockQuery.mockImplementation(
createMockSDKResponse([
{
text: 'API Error: 504 {"error":"upstream_timeout","message":"Upstream server timed out"}',
is_error: true,
},
]),
);

const result = await runAgent(makeAgentConfig(), 'Test prompt', makeOptions(), undefined, emitter);

expect(result.error).toBe(AgentErrorType.EXECUTION_ERROR);
expect(result.errorMessage).not.toMatch(/temporarily unavailable/);
expect(result.errorMessage).toMatch(/likely to fail the same way/);
});

it('detects a 503 as SERVICE_UNAVAILABLE', async () => {
mockQuery.mockImplementation(
createMockSDKResponse([{ text: 'API Error: 503 Service Unavailable', is_error: true }]),
);

const result = await runAgent(makeAgentConfig(), 'Test prompt', makeOptions(), undefined, emitter);

expect(result.error).toBe(AgentErrorType.SERVICE_UNAVAILABLE);
expect(result.errorMessage).toMatch(/temporarily unavailable/);
});
Expand All @@ -328,8 +363,7 @@ describe('service unavailability handling', () => {
});

it('skips validation retries when service is unavailable', async () => {
const apiErrorText = 'API Error: 500 {"error":{"type":"internal_error","message":"An unexpected error occurred"}}';
mockQuery.mockImplementation(createMockSDKResponse([{ text: apiErrorText, is_error: true }]));
mockQuery.mockImplementation(createMockSDKResponse([{ text: 'API Error: 503 overloaded_error', is_error: true }]));

const validateAndFormat = vi.fn().mockResolvedValue('Still broken');

Expand Down
Loading