Skip to content
Closed
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
16 changes: 16 additions & 0 deletions src/__tests__/mcp-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,13 @@
});

test('starts the TUI with the McpAdd program id', async () => {
mcpAddCommand.handler!(makeArgv());

Check warning on line 63 in src/__tests__/mcp-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
await flush();
expect(mockStartTUIMcp).toHaveBeenCalledWith(expect.any(String), 'mcp-add');
});

test('passes --local through as localMcp', async () => {
mcpAddCommand.handler!(makeArgv({ local: true }));

Check warning on line 69 in src/__tests__/mcp-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
await flush();
expect(mockBuildSessionMcp).toHaveBeenCalledWith(
expect.objectContaining({ localMcp: true }),
Expand All @@ -74,7 +74,7 @@
});

test('passes --api-key through to buildSession', async () => {
mcpAddCommand.handler!(makeArgv({ apiKey: 'phx_from_flag' }));

Check warning on line 77 in src/__tests__/mcp-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
await flush();
expect(mockBuildSessionMcp).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: 'phx_from_flag' }),
Expand All @@ -83,15 +83,31 @@

test('falls back to readApiKeyFromEnv when --api-key is omitted', async () => {
mockReadApiKeyFromEnvMcp.mockReturnValueOnce('phx_from_env');
mcpAddCommand.handler!(makeArgv());

Check warning on line 86 in src/__tests__/mcp-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
await flush();
expect(mockBuildSessionMcp).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: 'phx_from_env' }),
);
});

test('passes --base-url and --oauth-client-id through to buildSession', async () => {
mcpAddCommand.handler!(

Check warning on line 94 in src/__tests__/mcp-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
makeArgv({
baseUrl: 'https://posthog.example.com',
oauthClientId: 'own-client',
}),
);
await flush();
expect(mockBuildSessionMcp).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: 'https://posthog.example.com',
oauthClientId: 'own-client',
}),
);
});

test('parses --features into a trimmed array', async () => {
mcpAddCommand.handler!(makeArgv({ features: 'flags, errors , logs' }));

Check warning on line 110 in src/__tests__/mcp-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
await flush();
expect(mockBuildSessionMcp).toHaveBeenCalledWith(
expect.objectContaining({ mcpFeatures: ['flags', 'errors', 'logs'] }),
Expand Down Expand Up @@ -122,7 +138,7 @@
});

test('starts the TUI with the McpRemove program id', async () => {
mcpRemoveCommand.handler!(makeArgv());

Check warning on line 141 in src/__tests__/mcp-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
await flush();
expect(mockStartTUIMcp).toHaveBeenCalledWith(
expect.any(String),
Expand All @@ -131,7 +147,7 @@
});

test('passes --local through as localMcp', async () => {
mcpRemoveCommand.handler!(makeArgv({ local: true }));

Check warning on line 150 in src/__tests__/mcp-cli.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Forbidden non-null assertion
await flush();
expect(mockBuildSessionMcp).toHaveBeenCalledWith(
expect.objectContaining({ localMcp: true }),
Expand Down
3 changes: 2 additions & 1 deletion src/commands/basic-integration/ci-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { emitWizardError } from '@lib/errors';
type Options = Arguments & {
region?: string;
baseUrl?: string;
oauthClientId?: string;
installDir?: string;
apiKey?: string;
signup?: boolean;
Expand Down Expand Up @@ -167,7 +168,7 @@ async function provisionForSignup(
options.email as string,
options.name ?? '',
signupRegion,
{ baseUrl: options.baseUrl },
{ baseUrl: options.baseUrl, oauthClientId: options.oauthClientId },
);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
Expand Down
1 change: 1 addition & 0 deletions src/commands/mcp/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function runMcpAdd(argv: Arguments): void {
mcpFeatures: features,
apiKey,
baseUrl: argv.baseUrl as string | undefined,
oauthClientId: argv.oauthClientId as string | undefined,
});
} catch (error) {
if (!isTUIUnavailable(error)) throw error;
Expand Down
1 change: 1 addition & 0 deletions src/commands/mcp/tutorial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ function runMcpTutorial(argv: Arguments): void {
debug,
localMcp,
baseUrl: argv.baseUrl as string | undefined,
oauthClientId: argv.oauthClientId as string | undefined,
});
} catch (err) {
// TUI unavailable — the tutorial has no headless fallback.
Expand Down
8 changes: 7 additions & 1 deletion src/commands/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function runProvision(argv: Arguments): void {
region: (argv.region as string).toUpperCase() as 'US' | 'EU',
name: (argv.name as string) ?? '',
baseUrl: argv.baseUrl as string | undefined,
oauthClientId: argv.oauthClientId as string | undefined,
jsonMode,
});
}
Expand All @@ -55,6 +56,7 @@ type ProvisionArgs = {
region: 'US' | 'EU';
name: string;
baseUrl?: string;
oauthClientId?: string;
jsonMode: boolean;
};

Expand All @@ -63,14 +65,18 @@ async function provision({
region,
name,
baseUrl,
oauthClientId,
jsonMode,
}: ProvisionArgs): Promise<void> {
try {
const { provisionNewAccount } = await import('@utils/provisioning');
if (!jsonMode) {
getUI().log.info(`Provisioning account for ${email} in ${region}...`);
}
const result = await provisionNewAccount(email, name, region, { baseUrl });
const result = await provisionNewAccount(email, name, region, {
baseUrl,
oauthClientId,
});
emitResult(result, jsonMode);
process.exit(0);
} catch (error) {
Expand Down
1 change: 1 addition & 0 deletions src/commands/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function runSlackConnect(argv: Arguments): void {
tui.store.session = buildSession({
debug,
baseUrl: argv.baseUrl as string | undefined,
oauthClientId: argv.oauthClientId as string | undefined,
});
} catch (err) {
// TUI unavailable — connecting Slack has no headless fallback.
Expand Down
1 change: 1 addition & 0 deletions src/lib/agent/runner/shared/authenticate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function authenticate(
email: session.email,
region: session.region,
baseUrl: session.baseUrl,
oauthClientId: session.oauthClientId,
localMcp: session.localMcp,
programId,
});
Expand Down
1 change: 1 addition & 0 deletions src/lib/runners/run-non-interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export function runNonInteractive(
email: options.email as string | undefined,
projectId: options.projectId as string | undefined,
baseUrl: options.baseUrl as string | undefined,
oauthClientId: options.oauthClientId as string | undefined,
benchmark: options.benchmark as boolean | undefined,
yaraReport: options.yaraReport as boolean | undefined,
noTelemetry: resolveNoTelemetry(options),
Expand Down
2 changes: 2 additions & 0 deletions src/lib/runners/run-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export function runWizard(
projectId: options.projectId as string | undefined,
email: options.email as string | undefined,
baseUrl: options.baseUrl as string | undefined,
oauthClientId: options.oauthClientId as string | undefined,
benchmark: options.benchmark as boolean | undefined,
yaraReport: options.yaraReport as boolean | undefined,
noTelemetry: resolveNoTelemetry(options),
Expand Down Expand Up @@ -212,6 +213,7 @@ export function runWizard(
apiKey: session.apiKey,
projectId: session.projectId,
baseUrl: session.baseUrl,
oauthClientId: session.oauthClientId,
programId: config.id,
});
activeTui.store.setCredentials({
Expand Down
9 changes: 9 additions & 0 deletions src/lib/wizard-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,13 @@ export interface WizardSession {
* `@utils/urls`. Empty/unset → region-based resolution.
*/
baseUrl?: string;
/**
* Explicit OAuth client ID (`--oauth-client-id`). Overrides the client ID the
* login and provisioning flows would otherwise pick from `baseUrl`, so a
* pinned self-hosted instance can present a client its own OAuth app
* registers. Threaded into `@utils/oauth` and `@utils/provisioning`.
*/
oauthClientId?: string;
benchmark: boolean;
yaraReport: boolean;
projectId?: number;
Expand Down Expand Up @@ -428,6 +435,7 @@ export function buildSession(args: {
email?: string;
region?: CloudRegion;
baseUrl?: string;
oauthClientId?: string;
integration?: Integration;
benchmark?: boolean;
yaraReport?: boolean;
Expand Down Expand Up @@ -455,6 +463,7 @@ export function buildSession(args: {
// helper already honours. An explicit `--base-url` is more specific, so it wins.
baseUrl:
args.baseUrl ?? (local.localPosthog ? POSTHOG_LOCAL_URL : undefined),
oauthClientId: args.oauthClientId,
benchmark: args.benchmark ?? false,
yaraReport: args.yaraReport ?? false,
projectId: parseProjectIdArg(args.projectId),
Expand Down
2 changes: 2 additions & 0 deletions src/ui/tui/screens/SlackConnectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ export const SlackConnectScreen = ({ store }: SlackConnectScreenProps) => {
ci: false,
apiKey: undefined,
projectId: undefined,
baseUrl: store.session.baseUrl,
oauthClientId: store.session.oauthClientId,
programId: Program.SlackConnect,
});
if (cancelled) return;
Expand Down
1 change: 1 addition & 0 deletions src/ui/tui/services/mcp-suggested-prompts-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export function createMcpSuggestedPromptsServices(
email: undefined,
region: undefined,
baseUrl: store.session.baseUrl,
oauthClientId: store.session.oauthClientId,
// Widens the OAuth scope grant: base `WIZARD_OAUTH_SCOPES` plus
// read on every product surface (flags, experiments, surveys,
// replays, errors, web/LLM analytics, cohorts, persons) plus
Expand Down
1 change: 1 addition & 0 deletions src/utils/__tests__/oauth-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ describe('buildOAuthFailureMessage', () => {
'PostHog at http://localhost:8010 does not recognize',
);
expect(message).toContain('--base-url');
expect(message).toContain('--oauth-client-id');
expect(message).toContain('What to do:');
});

Expand Down
37 changes: 37 additions & 0 deletions src/utils/__tests__/provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,43 @@ describe('provisionNewAccount', () => {
);
});

it('sends an explicit oauthClientId instead of the region client', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: { id: 'req_cid', type: 'oauth', oauth: { code: 'code_cid' } },
})
.mockResolvedValueOnce({
data: {
token_type: 'bearer',
access_token: 'pha_cid',
refresh_token: 'phr_cid',
expires_in: 3600,
},
})
.mockResolvedValueOnce({
data: {
status: 'complete',
id: '8',
complete: {
access_configuration: {
api_key: 'phc_cid',
host: 'https://selfhosted.example.com',
},
},
},
});

await provisionNewAccount('self@example.com', '', 'US', {
baseUrl: 'https://selfhosted.example.com',
oauthClientId: 'my-instance-client-id',
});

const accountCall = mockedAxios.post.mock.calls[0];
expect((accountCall[1] as Record<string, unknown>).client_id).toBe(
'my-instance-client-id',
);
});

it('sends project name in resources configuration', async () => {
mockedAxios.post
.mockResolvedValueOnce({
Expand Down
2 changes: 1 addition & 1 deletion src/utils/oauth-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ function headlineAndRemediation(params: FailureMessageParams): {
return {
headline: `PostHog at ${oauthUrl} does not recognize the wizard's OAuth client (invalid_client).`,
whatToDo:
'The client ID below is not registered on the target instance. If you pointed the wizard at a local or self-hosted stack (--base-url), seed its wizard OAuth app first; otherwise re-run without --base-url to use PostHog Cloud.',
'The client ID below is not registered on the target instance. If you pointed the wizard at a local or self-hosted stack (--base-url), either seed its wizard OAuth app or pass --oauth-client-id with a client the instance registers; otherwise re-run without --base-url to use PostHog Cloud.',
};
case 'invalid_grant':
return {
Expand Down
27 changes: 16 additions & 11 deletions src/utils/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,19 +115,22 @@ interface OAuthConfig {
* OAuth server and selects the matching client ID.
*/
baseUrl?: string;
/**
* Explicit OAuth client ID (`--oauth-client-id`, from
* `session.oauthClientId`). Wins over the base-URL heuristic below.
*/
oauthClientId?: string;
}

/**
* OAuth client ID for the current target. A pinned base URL (`--base-url`, or
* IS_DEV's implicit localhost) means we're talking to a dev-seeded stack, which
* registers the dev client; prod uses the proxy client.
*
* TODO: this assumes any pinned base URL is a dev-seeded instance that
* registers POSTHOG_DEV_CLIENT_ID. If we ever point `--base-url` at a non-dev
* instance with its own OAuth app, make the client ID configurable (e.g. a
* `--oauth-client-id` flag) instead of always falling back to the dev client.
* OAuth client ID for the current target. An explicit `--oauth-client-id` wins,
* so a pinned instance with its own OAuth app can present a client it registers.
* Otherwise a pinned base URL (`--base-url`, or IS_DEV's implicit localhost)
* means a dev-seeded stack, which registers the dev client; prod uses the proxy
* client.
*/
function getOAuthClientId(baseUrl?: string): string {
function getOAuthClientId(baseUrl?: string, oauthClientId?: string): string {
if (oauthClientId) return oauthClientId;
return resolveBaseUrl(baseUrl)
? POSTHOG_DEV_CLIENT_ID
: POSTHOG_PROXY_CLIENT_ID;
Expand Down Expand Up @@ -351,8 +354,9 @@ async function exchangeCodeForToken(
codeVerifier: string,
callbackUrl: string,
baseUrl?: string,
oauthClientId?: string,
): Promise<OAuthTokenResponse> {
const clientId = getOAuthClientId(baseUrl);
const clientId = getOAuthClientId(baseUrl, oauthClientId);
const oauthUrl = getOAuthUrl(baseUrl);

logToFile(`[oauth] exchanging code for token at ${oauthUrl}/oauth/token`);
Expand Down Expand Up @@ -456,7 +460,7 @@ function reportNarrowedGrant(
export async function performOAuthFlow(
config: OAuthConfig,
): Promise<OAuthTokenResponse> {
const clientId = getOAuthClientId(config.baseUrl);
const clientId = getOAuthClientId(config.baseUrl, config.oauthClientId);
const oauthUrl = getOAuthUrl(config.baseUrl);
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
Expand Down Expand Up @@ -559,6 +563,7 @@ export async function performOAuthFlow(
codeVerifier,
callbackUrl,
config.baseUrl,
config.oauthClientId,
);

server.close();
Expand Down
22 changes: 14 additions & 8 deletions src/utils/provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,18 @@ const getProvisioningBaseUrl = (
};

/**
* OAuth client ID for provisioning. A pinned base URL means a dev-seeded stack
* that registers the dev client; prod uses the client registered for the target
* region (the wizard OAuth app is registered separately per region).
*
* TODO: same assumption as `getOAuthClientId` in oauth.ts — a pinned base URL is
* treated as a dev-seeded instance. Make configurable if we ever point
* `--base-url` at a non-dev instance with its own OAuth app.
* OAuth client ID for provisioning. An explicit `--oauth-client-id` wins, so a
* pinned instance with its own OAuth app can present a client it registers.
* Otherwise a pinned base URL means a dev-seeded stack that registers the dev
* client; prod uses the client registered for the target region (the wizard
* OAuth app is registered separately per region).
*/
const getProvisioningClientId = (
region: 'US' | 'EU',
baseUrl?: string,
oauthClientId?: string,
): string => {
if (oauthClientId) return oauthClientId;
if (resolveBaseUrl(baseUrl)) return POSTHOG_DEV_CLIENT_ID;
return region === 'EU' ? POSTHOG_EU_CLIENT_ID : POSTHOG_US_CLIENT_ID;
};
Expand Down Expand Up @@ -290,6 +290,8 @@ export async function provisionNewAccount(
orgName?: string;
projectName?: string;
baseUrl?: string;
/** Explicit OAuth client ID (`--oauth-client-id`); wins over the base-URL heuristic. */
oauthClientId?: string;
/** Scope list to request; defaults to `WIZARD_PROVISIONING_SCOPES`. */
scopes?: readonly string[];
},
Expand All @@ -307,7 +309,11 @@ export async function provisionNewAccount(
id: crypto.randomUUID(),
email,
name,
client_id: getProvisioningClientId(region, opts?.baseUrl),
client_id: getProvisioningClientId(
region,
opts?.baseUrl,
opts?.oauthClientId,
),
code_challenge: codeChallenge,
code_challenge_method: 'S256',
scopes: opts?.scopes ?? WIZARD_PROVISIONING_SCOPES,
Expand Down
Loading
Loading