From 960ee2f4bdf6627af601a371985bfc96f39aede5 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 00:24:36 +0000 Subject: [PATCH] fix: route pre-agent PostHog API 401s through AuthErrorScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 401 from `fetchProjectData` (raised before the Claude Code agent boots) propagated unhandled — CI mode rendered a generic "Something went wrong" and the TUI silently exited unless DEBUG was set, bypassing the AuthErrorScreen flow added in #432 for SDK-side 401s. Catch `ApiError` with `statusCode === 401` around both `getOrAskForProjectData` call sites and route through the same `showAuthError` + `wizardAbort` flow used by the SDK-side handler in `agent-interface.ts`. Also surface unexpected errors from the TUI catch in `bin.ts` instead of swallowing them when DEBUG is off. Generated-By: PostHog Code Task-Id: d476f6aa-7a61-4799-8821-37d442cde0fe --- bin.ts | 24 +++++-- src/lib/agent/agent-runner.ts | 16 ++++- .../handle-project-data-auth-error.test.ts | 69 +++++++++++++++++++ src/utils/setup-utils.ts | 28 +++++++- 4 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 src/utils/__tests__/handle-project-data-auth-error.test.ts diff --git a/bin.ts b/bin.ts index 96b64953..9a5e06dc 100644 --- a/bin.ts +++ b/bin.ts @@ -690,16 +690,21 @@ function runWizard( const skipAgent = config.run == null; if (skipAgent) { - const { getOrAskForProjectData } = await import( - './src/utils/setup-utils.js' - ); - const { projectApiKey, host, accessToken, projectId } = - await getOrAskForProjectData({ + const { getOrAskForProjectData, handleProjectDataAuthError } = + await import('./src/utils/setup-utils.js'); + let projectData: Awaited>; + try { + projectData = await getOrAskForProjectData({ signup: session.signup, ci: session.ci, apiKey: session.apiKey, projectId: session.projectId, }); + } catch (error) { + await handleProjectDataAuthError(error); + throw error; + } + const { projectApiKey, host, accessToken, projectId } = projectData; tui.store.setCredentials({ accessToken, projectApiKey, @@ -737,9 +742,16 @@ function runWizard( tui.unmount(); process.exit(0); } catch (err) { + // Surface unexpected errors instead of exiting cleanly — silent failures + // here mask things like PostHog-API 401s thrown before the agent starts. + const errorMessage = err instanceof Error ? err.message : String(err); + // eslint-disable-next-line no-console + console.error(`Wizard failed: ${errorMessage}`); if (runtimeEnv('DEBUG') || runtimeEnv('POSTHOG_WIZARD_DEBUG')) { - console.error('TUI init failed:', err); // eslint-disable-line no-console + // eslint-disable-next-line no-console + console.error(err); } + process.exit(1); } })(); } diff --git a/src/lib/agent/agent-runner.ts b/src/lib/agent/agent-runner.ts index 0691f242..fa6492c9 100644 --- a/src/lib/agent/agent-runner.ts +++ b/src/lib/agent/agent-runner.ts @@ -20,7 +20,10 @@ import { type Credentials, OutroKind, } from '../wizard-session'; -import { getOrAskForProjectData } from '../../utils/setup-utils'; +import { + getOrAskForProjectData, + handleProjectDataAuthError, +} from '../../utils/setup-utils'; import { analytics } from '../../utils/analytics'; import { getUI } from '../../ui'; import { @@ -262,8 +265,9 @@ export async function runProgram( // 4. OAuth logToFile('[agent-runner] starting OAuth'); - const { projectApiKey, host, accessToken, projectId, cloudRegion } = - await getOrAskForProjectData({ + let projectData: Awaited>; + try { + projectData = await getOrAskForProjectData({ signup: session.signup, ci: session.ci, apiKey: session.apiKey, @@ -271,6 +275,12 @@ export async function runProgram( email: session.email, region: session.region, }); + } catch (error) { + await handleProjectDataAuthError(error); + throw error; + } + const { projectApiKey, host, accessToken, projectId, cloudRegion } = + projectData; session.credentials = { accessToken, projectApiKey, host, projectId }; getUI().setCredentials(session.credentials); diff --git a/src/utils/__tests__/handle-project-data-auth-error.test.ts b/src/utils/__tests__/handle-project-data-auth-error.test.ts new file mode 100644 index 00000000..2fa65a8f --- /dev/null +++ b/src/utils/__tests__/handle-project-data-auth-error.test.ts @@ -0,0 +1,69 @@ +import { ApiError } from '../../lib/api'; +import { handleProjectDataAuthError } from '../setup-utils'; +import { analytics } from '../analytics'; + +jest.mock('../analytics'); +jest.mock('../../ui', () => ({ + getUI: jest.fn().mockReturnValue({ + showAuthError: jest.fn(), + outroError: jest.fn(), + waitForOutroDismissed: jest.fn().mockResolvedValue(undefined), + }), +})); +jest.mock('../debug', () => ({ + getLogFilePath: jest.fn().mockReturnValue('/tmp/wizard.log'), + debug: jest.fn(), + logToFile: jest.fn(), +})); + +const mockAnalytics = analytics as jest.Mocked; +const { getUI } = jest.requireMock('../../ui'); + +describe('handleProjectDataAuthError', () => { + beforeEach(() => { + jest.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/require-await + mockAnalytics.shutdown = jest.fn().mockImplementation(async () => {}); + mockAnalytics.captureException = jest.fn(); + jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('routes a 401 ApiError through showAuthError and aborts', async () => { + const err = new ApiError('Authentication failed', 401, '/api/projects/1/'); + + await expect(handleProjectDataAuthError(err)).rejects.toThrow( + 'process.exit called', + ); + + expect(getUI().showAuthError).toHaveBeenCalledWith({ + hasSettingsConflict: false, + logFilePath: '/tmp/wizard.log', + }); + expect(mockAnalytics.captureException).toHaveBeenCalledWith(err, {}); + expect(process.exit).toHaveBeenCalledWith(1); + }); + + it('returns without action for non-401 ApiError', async () => { + const err = new ApiError('Not found', 404, '/api/projects/1/'); + + await expect(handleProjectDataAuthError(err)).resolves.toBeUndefined(); + + expect(getUI().showAuthError).not.toHaveBeenCalled(); + expect(process.exit).not.toHaveBeenCalled(); + }); + + it('returns without action for non-ApiError errors', async () => { + await expect( + handleProjectDataAuthError(new Error('boom')), + ).resolves.toBeUndefined(); + + expect(getUI().showAuthError).not.toHaveBeenCalled(); + expect(process.exit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/setup-utils.ts b/src/utils/setup-utils.ts index 6eff0b75..7703a742 100644 --- a/src/utils/setup-utils.ts +++ b/src/utils/setup-utils.ts @@ -29,9 +29,10 @@ import { } from './urls'; import { performOAuthFlow } from './oauth'; import { provisionNewAccount } from './provisioning'; -import { fetchUserData, fetchProjectData } from '../lib/api'; +import { ApiError, fetchUserData, fetchProjectData } from '../lib/api'; import { fulfillsVersionRange } from './semver'; import { wizardAbort } from './wizard-abort'; +import { getLogFilePath } from './debug'; interface ProjectData { projectApiKey: string; @@ -372,6 +373,31 @@ export function isUsingTypeScript({ } } +/** + * Route a 401 from `getOrAskForProjectData` through the same AuthErrorScreen + * UX the agent-side 401 handler uses, then abort. Returns for non-401 errors + * so the caller can re-throw and let the generic error path render. + * + * Mirrors the flow in `agent-interface.ts` for SDK-side 401s, which only fires + * after the agent boots — PostHog API 401s thrown earlier need their own entry + * point or the run aborts opaquely. + */ +export async function handleProjectDataAuthError( + error: unknown, +): Promise { + if (!(error instanceof ApiError) || error.statusCode !== 401) { + return; + } + getUI().showAuthError({ + hasSettingsConflict: false, + logFilePath: getLogFilePath(), + }); + await wizardAbort({ + message: 'Authentication failed (401)', + error, + }); +} + /** * Get project data for the wizard via OAuth or CI API key. */