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
92 changes: 89 additions & 3 deletions src/utils/__tests__/provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,15 @@ describe('provisionNewAccount', () => {
},
});

const result = await provisionNewAccount('user@example.com', 'Test User');
const result = await provisionNewAccount(
'user@example.com',
'Test User',
'US',
{
orgName: 'acme-corp',
projectName: 'my-app',
},
);

expect(result).toEqual({
accessToken: 'pha_test_access',
Expand All @@ -72,7 +80,10 @@ describe('provisionNewAccount', () => {
email: 'user@example.com',
name: 'Test User',
code_challenge_method: 'S256',
configuration: { region: 'US' },
configuration: {
region: 'US',
organization_name: 'acme-corp',
},
});
expect(
(accountCall[1] as Record<string, unknown>).code_challenge,
Expand All @@ -85,9 +96,13 @@ describe('provisionNewAccount', () => {
expect(tokenCall[1]).toContain('code_verifier=');
expect(tokenCall[1]).toContain('grant_type=authorization_code');

// Verify resources call uses bearer token
// Verify resources call uses bearer token and project name
const resourceCall = mockedAxios.post.mock.calls[2];
expect(resourceCall[0]).toContain('/resources');
expect(resourceCall[1]).toMatchObject({
service_id: 'analytics',
configuration: { project_name: 'my-app' },
});
expect(resourceCall[2]?.headers?.Authorization).toBe(
'Bearer pha_test_access',
);
Expand Down Expand Up @@ -179,6 +194,77 @@ describe('provisionNewAccount', () => {
expect(result.host).toBe('https://eu.posthog.com');
});

it('sends project name in resources configuration', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: { id: 'req_p', type: 'oauth', oauth: { code: 'code_p' } },
})
.mockResolvedValueOnce({
data: {
token_type: 'bearer',
access_token: 'pha_p',
refresh_token: 'phr_p',
expires_in: 3600,
},
})
.mockResolvedValueOnce({
data: {
status: 'complete',
id: '50',
service_id: 'analytics',
complete: {
access_configuration: {
api_key: 'phc_p',
host: 'https://us.posthog.com',
},
},
},
});

await provisionNewAccount('proj@example.com', '', 'US', {
projectName: 'my-cool-app',
});

const resourceCall = mockedAxios.post.mock.calls[2];
expect(resourceCall[1]).toMatchObject({
service_id: 'analytics',
configuration: { project_name: 'my-cool-app' },
});
});

it('omits project name when not provided', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: { id: 'req_np', type: 'oauth', oauth: { code: 'code_np' } },
})
.mockResolvedValueOnce({
data: {
token_type: 'bearer',
access_token: 'pha_np',
refresh_token: 'phr_np',
expires_in: 3600,
},
})
.mockResolvedValueOnce({
data: {
status: 'complete',
id: '51',
service_id: 'analytics',
complete: {
access_configuration: {
api_key: 'phc_np',
host: 'https://us.posthog.com',
},
},
},
});

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

const resourceCall = mockedAxios.post.mock.calls[2];
expect(resourceCall[1]).toEqual({ service_id: 'analytics' });
});

it('includes timeouts on all requests', async () => {
mockedAxios.post
.mockResolvedValueOnce({
Expand Down
22 changes: 18 additions & 4 deletions src/utils/provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@
import * as crypto from 'node:crypto';
import axios from 'axios';
import { z } from 'zod';
import { IS_DEV, WIZARD_USER_AGENT } from '../lib/constants';
import {
IS_DEV,
POSTHOG_DEV_CLIENT_ID,
POSTHOG_US_CLIENT_ID,
WIZARD_USER_AGENT,
} from '../lib/constants';
import { logToFile } from './debug';
import { analytics } from './analytics';

const WIZARD_CLIENT_ID = 'posthog-wizard';
const WIZARD_CLIENT_ID = IS_DEV ? POSTHOG_DEV_CLIENT_ID : POSTHOG_US_CLIENT_ID;
const API_VERSION = '0.1d';

const PROVISIONING_BASE_URL = IS_DEV
Expand Down Expand Up @@ -95,6 +100,7 @@ export async function provisionNewAccount(
email: string,
name: string,
region: 'US' | 'EU' = 'US',
opts?: { orgName?: string; projectName?: string },
): Promise<ProvisioningResult> {
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
Expand All @@ -111,7 +117,10 @@ export async function provisionNewAccount(
client_id: WIZARD_CLIENT_ID,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
configuration: { region },
configuration: {
region,
...(opts?.orgName ? { organization_name: opts.orgName } : {}),
},
},
{
headers: {
Expand Down Expand Up @@ -172,7 +181,12 @@ export async function provisionNewAccount(
// Step 3: Provision resources
const resourceRes = await axios.post(
`${PROVISIONING_BASE_URL}/api/agentic/provisioning/resources`,
{ service_id: 'analytics' },
{
service_id: 'analytics',
...(opts?.projectName
? { configuration: { project_name: opts.projectName } }
: {}),
},
{
headers: {
'Content-Type': 'application/json',
Expand Down
67 changes: 65 additions & 2 deletions src/utils/setup-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as childProcess from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { isAbsolute, join, relative } from 'node:path';
import { basename, isAbsolute, join, relative } from 'node:path';

import { traceStep } from '../telemetry';
import { debug } from './debug';
Expand Down Expand Up @@ -77,6 +77,65 @@ export function isInGitRepo() {
}
}

const FREEMAIL_DOMAINS = new Set([
'gmail.com',
'googlemail.com',
'hotmail.com',
'outlook.com',
'yahoo.com',
'icloud.com',
'me.com',
'mail.com',
'protonmail.com',
'proton.me',
'live.com',
'aol.com',
'yandex.com',
'zoho.com',
'gmx.com',
'fastmail.com',
]);

function parseGitRemote(): { org: string; repo: string } | null {
try {
const url = childProcess
.execSync('git remote get-url origin', {
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
// git@github.com:acme-corp/my-app.git or https://github.com/acme-corp/my-app.git
const match = url.match(/[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
if (match) return { org: match[1], repo: match[2] };
} catch {
// not in a git repo or no remote
}
return null;
}

export function detectOrgAndProject(email: string): {
orgName: string | undefined;
projectName: string | undefined;
} {
const remote = parseGitRemote();

// Project name: git repo name > directory name
const projectName = remote?.repo || basename(process.cwd()) || undefined;

// Org name: git remote org > email domain (skip freemail)
let orgName: string | undefined;
if (remote?.org) {
orgName = remote.org;
} else {
const domain = email.split('@')[1]?.toLowerCase();
if (domain && !FREEMAIL_DOMAINS.has(domain)) {
orgName = domain.split('.')[0];
}
}

return { orgName, projectName };
}

export function getUncommittedOrUntrackedFiles(): string[] {
try {
const gitStatus = childProcess
Expand Down Expand Up @@ -500,7 +559,11 @@ async function askForProvisioningSignup(

try {
const provisionRegion = (region ?? 'us').toUpperCase() as 'US' | 'EU';
const result = await provisionNewAccount(email, '', provisionRegion);
const { orgName, projectName } = detectOrgAndProject(email);
const result = await provisionNewAccount(email, '', provisionRegion, {
orgName,
projectName,
});

spinner.stop('Account created!');
getUI().log.success('Welcome to PostHog!');
Expand Down
Loading