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

const WIZARD_VERSION = VERSION;

Check warning on line 9 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 @@ -87,6 +87,11 @@
'PostHog project ID to use (optional; when not set, uses default from API key or OAuth)\nenv: POSTHOG_WIZARD_PROJECT_ID',
type: 'string',
},
email: {
describe:
'Email address for account creation (used with --signup)\nenv: POSTHOG_WIZARD_EMAIL',
type: 'string',
},
})
.command(
['$0'],
Expand Down Expand Up @@ -187,7 +192,7 @@
const { startPlayground } = await import(
'./src/ui/tui/playground/start-playground.js'
);
(startPlayground as (version: string) => void)(WIZARD_VERSION);

Check warning on line 195 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 {
// Interactive TTY: launch the Ink TUI
Expand All @@ -198,7 +203,7 @@
'./src/lib/wizard-session.js'
);

const tui = startTUI(WIZARD_VERSION);

Check warning on line 206 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

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

// Build session from CLI args and attach to store
const session = buildSession({
Expand All @@ -209,6 +214,7 @@
signup: options.signup as boolean | undefined,
localMcp: options.localMcp as boolean | undefined,
apiKey: options.apiKey as string | undefined,
email: options.email,
menu: options.menu as boolean | undefined,
integration: options.integration as Parameters<
typeof buildSession
Expand Down Expand Up @@ -320,10 +326,10 @@
// Feature discovery — deterministic scan of package.json deps
try {
const { readFileSync } = await import('fs');
const pkgPath = require('path').join(installDir, 'package.json');

Check warning on line 329 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe call of an `any` typed value

Check warning on line 329 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .join on an `any` value

Check warning on line 329 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));

Check warning on line 330 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

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

Check warning on line 330 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
const allDeps = {

Check warning on line 331 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
...pkg.dependencies,

Check warning on line 332 in bin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe member access .dependencies on an `any` value
...pkg.devDependencies,
};
const depNames = Object.keys(allDeps);
Expand Down
2 changes: 2 additions & 0 deletions src/lib/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ export async function runAgentWizard(
signup: session.signup,
ci: session.ci,
apiKey: session.apiKey,
email: session.email,
region: session.region,
projectId: session.projectId,
});

Expand Down
6 changes: 6 additions & 0 deletions src/lib/wizard-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export interface WizardSession {
localMcp: boolean;
mcpFeatures?: string[];
apiKey?: string;
email?: string;
region?: CloudRegion;
menu: boolean;
benchmark: boolean;
yaraReport: boolean;
Expand Down Expand Up @@ -163,6 +165,8 @@ export function buildSession(args: {
localMcp?: boolean;
mcpFeatures?: string[];
apiKey?: string;
email?: string;
region?: CloudRegion;
menu?: boolean;
integration?: Integration;
benchmark?: boolean;
Expand All @@ -178,6 +182,8 @@ export function buildSession(args: {
localMcp: args.localMcp ?? false,
mcpFeatures: args.mcpFeatures,
apiKey: args.apiKey,
email: args.email,
region: args.region,
menu: args.menu ?? false,
benchmark: args.benchmark ?? false,
yaraReport: args.yaraReport ?? false,
Expand Down
3 changes: 3 additions & 0 deletions src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type Args = {
localMcp?: boolean;
ci?: boolean;
apiKey?: string;
email?: string;
projectId?: string;
menu?: boolean;
benchmark?: boolean;
Expand Down Expand Up @@ -68,6 +69,8 @@ export async function runWizard(argv: Args, session?: WizardSession) {
signup: finalArgs.signup,
localMcp: finalArgs.localMcp,
apiKey: finalArgs.apiKey,
email: finalArgs.email,
region: finalArgs.region,
menu: finalArgs.menu,
integration: finalArgs.integration,
benchmark: finalArgs.benchmark,
Expand Down
226 changes: 226 additions & 0 deletions src/utils/__tests__/provisioning.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import axios from 'axios';
import { provisionNewAccount } from '../provisioning';

jest.mock('axios');
jest.mock('../debug', () => ({ logToFile: jest.fn() }));
jest.mock('../analytics', () => ({
analytics: { captureException: jest.fn() },
}));

const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('provisionNewAccount', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('completes the full PKCE flow and returns credentials', async () => {
// Step 1: account_requests
mockedAxios.post.mockResolvedValueOnce({
data: {
id: 'req_1',
type: 'oauth',
oauth: { code: 'test_code_123' },
},
});

// Step 2: oauth/token
mockedAxios.post.mockResolvedValueOnce({
data: {
token_type: 'bearer',
access_token: 'pha_test_access',
refresh_token: 'phr_test_refresh',
expires_in: 3600,
account: { id: 'org_123' },
},
});

// Step 3: resources
mockedAxios.post.mockResolvedValueOnce({
data: {
status: 'complete',
id: '42',
service_id: 'analytics',
complete: {
access_configuration: {
api_key: 'phc_test_key',
host: 'https://us.posthog.com',
personal_api_key: 'phx_test_pat',
},
},
},
});

const result = await provisionNewAccount('user@example.com', 'Test User');

expect(result).toEqual({
accessToken: 'pha_test_access',
refreshToken: 'phr_test_refresh',
projectApiKey: 'phc_test_key',
host: 'https://us.posthog.com',
personalApiKey: 'phx_test_pat',
projectId: '42',
accountId: 'org_123',
});

expect(mockedAxios.post).toHaveBeenCalledTimes(3);

// Verify account_requests call
const accountCall = mockedAxios.post.mock.calls[0];
expect(accountCall[0]).toContain('/account_requests');
expect(accountCall[1]).toMatchObject({
email: 'user@example.com',
name: 'Test User',
code_challenge_method: 'S256',
configuration: { region: 'US' },
});
expect(
(accountCall[1] as Record<string, unknown>).code_challenge,
).toBeTruthy();
expect((accountCall[1] as Record<string, unknown>).client_id).toBeTruthy();

// Verify token exchange includes code_verifier
const tokenCall = mockedAxios.post.mock.calls[1];
expect(tokenCall[0]).toContain('/oauth/token');
expect(tokenCall[1]).toContain('code_verifier=');
expect(tokenCall[1]).toContain('grant_type=authorization_code');

// Verify resources call uses bearer token
const resourceCall = mockedAxios.post.mock.calls[2];
expect(resourceCall[0]).toContain('/resources');
expect(resourceCall[2]?.headers?.Authorization).toBe(
'Bearer pha_test_access',
);
});

it('throws when account already exists', async () => {
mockedAxios.post.mockResolvedValueOnce({
data: {
id: 'req_2',
type: 'requires_auth',
requires_auth: { type: 'redirect', redirect: { url: 'https://...' } },
},
});

await expect(
provisionNewAccount('existing@example.com', ''),
).rejects.toThrow('already associated');
});

it('throws on API error response', async () => {
mockedAxios.post.mockResolvedValueOnce({
data: {
id: 'req_3',
type: 'error',
error: { code: 'forbidden', message: 'Account creation disabled' },
},
});

await expect(
provisionNewAccount('blocked@example.com', ''),
).rejects.toThrow('Account creation disabled');
});

it('throws when resource provisioning fails', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: { id: 'req_4', type: 'oauth', oauth: { code: 'code_4' } },
})
.mockResolvedValueOnce({
data: {
token_type: 'bearer',
access_token: 'pha_4',
refresh_token: 'phr_4',
expires_in: 3600,
},
})
.mockResolvedValueOnce({
data: { status: 'error', id: '0', service_id: 'analytics' },
});

await expect(provisionNewAccount('fail@example.com', '')).rejects.toThrow(
'did not complete',
);
});

it('sends correct region parameter', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: { id: 'req_5', type: 'oauth', oauth: { code: 'code_5' } },
})
.mockResolvedValueOnce({
data: {
token_type: 'bearer',
access_token: 'pha_5',
refresh_token: 'phr_5',
expires_in: 3600,
},
})
.mockResolvedValueOnce({
data: {
status: 'complete',
id: '99',
service_id: 'analytics',
complete: {
access_configuration: {
api_key: 'phc_eu',
host: 'https://eu.posthog.com',
},
},
},
});

const result = await provisionNewAccount('eu@example.com', '', 'EU');

const accountCall = mockedAxios.post.mock.calls[0];
expect((accountCall[1] as Record<string, unknown>).configuration).toEqual({
region: 'EU',
});
expect(result.host).toBe('https://eu.posthog.com');
});

it('includes timeouts on all requests', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: { id: 'req_6', type: 'oauth', oauth: { code: 'code_6' } },
})
.mockResolvedValueOnce({
data: {
token_type: 'bearer',
access_token: 'pha_6',
refresh_token: 'phr_6',
expires_in: 3600,
},
})
.mockResolvedValueOnce({
data: {
status: 'complete',
id: '1',
service_id: 'analytics',
complete: {
access_configuration: {
api_key: 'phc_t',
host: 'https://us.posthog.com',
},
},
},
});

await provisionNewAccount('timeout@example.com', '');

// account_requests and resources have config at index 2
const accountConfig = mockedAxios.post.mock.calls[0][2] as
| Record<string, unknown>
| undefined;
const resourceConfig = mockedAxios.post.mock.calls[2][2] as
| Record<string, unknown>
| undefined;
expect(accountConfig?.timeout).toBe(30_000);
expect(resourceConfig?.timeout).toBe(30_000);
// token exchange has config at index 2 (URL-encoded body is at index 1)
const tokenConfig = mockedAxios.post.mock.calls[1][2] as
| Record<string, unknown>
| undefined;
expect(tokenConfig?.timeout).toBe(30_000);
});
});
Loading
Loading