Skip to content
Draft
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
24 changes: 18 additions & 6 deletions bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { hideBin } from 'yargs/helpers';
import { VERSION } from './src/lib/version.js';

const WIZARD_VERSION = VERSION;

Check warning on line 8 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value

const NODE_VERSION_RANGE = '>=18.17.0';

Expand Down Expand Up @@ -364,7 +364,7 @@
const { startPlayground } = await import(
'./src/ui/tui/playground/start-playground.js'
);
(startPlayground as (version: string) => void)(WIZARD_VERSION);

Check warning on line 367 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `string`
})();
} else if (options.skill) {
// Run a specific skill by ID
Expand Down Expand Up @@ -443,7 +443,7 @@
'./src/lib/wizard-session.js'
);

const tui = startTUI(WIZARD_VERSION, Program.McpAdd);

Check warning on line 446 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `string`
const session = buildSession({
debug: options.debug,
localMcp: options.local,
Expand Down Expand Up @@ -488,7 +488,7 @@
'./src/lib/wizard-session.js'
);

const tui = startTUI(WIZARD_VERSION, Program.McpRemove);

Check warning on line 491 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `string`
const session = buildSession({
debug: options.debug,
localMcp: options.local,
Expand Down Expand Up @@ -598,7 +598,7 @@
// ── Skill-based program subcommands (derived from registry) ─────────
for (const programConfig of getSubcommandPrograms()) {
cli.command(
programConfig.command!,

Check warning on line 601 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
programConfig.description,
(y) =>
y.options({
Expand Down Expand Up @@ -649,7 +649,7 @@
);
const { analytics } = await import('./src/utils/analytics.js');

const tui = startTUI(WIZARD_VERSION, config.id as any);

Check warning on line 652 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type

Check warning on line 652 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `string`

Check warning on line 652 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe argument of type `any` assigned to a parameter of type `string`

const session = buildSession({
debug: options.debug as boolean | undefined,
Expand All @@ -662,7 +662,7 @@
projectId: options.projectId as string | undefined,
email: options.email as string | undefined,
menu: options.menu as boolean | undefined,
integration: options.integration as any,

Check warning on line 665 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type

Check warning on line 665 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
benchmark: options.benchmark as boolean | undefined,
yaraReport: options.yaraReport as boolean | undefined,
});
Expand Down Expand Up @@ -690,16 +690,21 @@
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<ReturnType<typeof getOrAskForProjectData>>;
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,
Expand Down Expand Up @@ -737,9 +742,16 @@
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);
}
})();
}
Expand Down
16 changes: 13 additions & 3 deletions src/lib/agent/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -262,15 +265,22 @@ export async function runProgram(

// 4. OAuth
logToFile('[agent-runner] starting OAuth');
const { projectApiKey, host, accessToken, projectId, cloudRegion } =
await getOrAskForProjectData({
let projectData: Awaited<ReturnType<typeof getOrAskForProjectData>>;
try {
projectData = await getOrAskForProjectData({
signup: session.signup,
ci: session.ci,
apiKey: session.apiKey,
projectId: session.projectId,
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);
Expand Down
69 changes: 69 additions & 0 deletions src/utils/__tests__/handle-project-data-auth-error.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof analytics>;
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();
});
});
28 changes: 27 additions & 1 deletion src/utils/setup-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
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.
*/
Expand Down
Loading