diff --git a/src/utils/__tests__/provisioning.test.ts b/src/utils/__tests__/provisioning.test.ts index ba461646..da640834 100644 --- a/src/utils/__tests__/provisioning.test.ts +++ b/src/utils/__tests__/provisioning.test.ts @@ -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', @@ -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).code_challenge, @@ -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', ); @@ -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({ diff --git a/src/utils/provisioning.ts b/src/utils/provisioning.ts index 15f0e0af..a2330c8f 100644 --- a/src/utils/provisioning.ts +++ b/src/utils/provisioning.ts @@ -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 @@ -95,6 +100,7 @@ export async function provisionNewAccount( email: string, name: string, region: 'US' | 'EU' = 'US', + opts?: { orgName?: string; projectName?: string }, ): Promise { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); @@ -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: { @@ -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', diff --git a/src/utils/setup-utils.ts b/src/utils/setup-utils.ts index 404d3eda..27ca5f8c 100644 --- a/src/utils/setup-utils.ts +++ b/src/utils/setup-utils.ts @@ -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'; @@ -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 @@ -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!');