diff --git a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts index ac5490201..c5085fafc 100644 --- a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts +++ b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts @@ -17,6 +17,7 @@ import { WizardStore } from '@ui/tui/store'; import { InkUI } from '@ui/tui/ink-ui'; import { setUI } from '@ui/index'; import { buildSession, RunPhase } from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; import { Integration } from '@lib/constants'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { WizardReadiness } from '@lib/health-checks/readiness'; @@ -66,7 +67,7 @@ function traceFlow( store.setCredentials({ accessToken: 'phx_x', projectApiKey: 'phc_x', - host: 'https://us.posthog.com', + host: HostResolution.fromApiHost('https://us.posthog.com'), projectId: 1, }); } else if (screen === ScreenId.Run) { diff --git a/e2e-harness/__tests__/wizard-ci-driver.test.ts b/e2e-harness/__tests__/wizard-ci-driver.test.ts index 230923b57..0cdb7e984 100644 --- a/e2e-harness/__tests__/wizard-ci-driver.test.ts +++ b/e2e-harness/__tests__/wizard-ci-driver.test.ts @@ -13,6 +13,7 @@ import { WizardStore } from '@ui/tui/store'; import { InkUI } from '@ui/tui/ink-ui'; import { setUI } from '@ui/index'; import { buildSession, RunPhase, McpOutcome } from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; import { Integration } from '@lib/constants'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { WizardReadiness } from '@lib/health-checks/readiness'; @@ -72,7 +73,7 @@ describe('WizardCiDriver — full integration flow', () => { store.setCredentials({ accessToken: 'phx_secret_should_not_leak', projectApiKey: 'phc_public', - host: 'https://us.posthog.com', + host: HostResolution.fromApiHost('https://us.posthog.com'), projectId: 42, }); @@ -112,7 +113,7 @@ describe('WizardCiDriver — full integration flow', () => { store.setCredentials({ accessToken: 'phx_secret_should_not_leak', projectApiKey: 'phc_public', - host: 'https://us.posthog.com', + host: HostResolution.fromApiHost('https://us.posthog.com'), projectId: 7, }); const state = driver.readState(); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f3088ff92..792349493 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -58,7 +58,11 @@ async function runDoctorCI(options: Record): Promise { baseUrl: options.baseUrl as string | undefined, }); - const issues = await fetchHealthIssues(accessToken, host, projectId); + const issues = await fetchHealthIssues( + accessToken, + host.apiHost, + projectId, + ); if (issues.length === 0) { getUI().log.success('No active issues — your project looks healthy.'); process.exit(0); diff --git a/src/lib/agent/__tests__/agent-prompt-loader.test.ts b/src/lib/agent/__tests__/agent-prompt-loader.test.ts index 4d9f7a7e6..7d9b54c5a 100644 --- a/src/lib/agent/__tests__/agent-prompt-loader.test.ts +++ b/src/lib/agent/__tests__/agent-prompt-loader.test.ts @@ -13,6 +13,7 @@ import { type OrchestratorPromptContext, } from '../agent-prompt-loader'; import { QueueStore } from '@lib/agent/runner/sequence/orchestrator/queue'; +import { HostResolution } from '@lib/host-resolution'; function tmpDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loader-test-')); @@ -280,7 +281,7 @@ describe('assembleTaskPrompt', () => { const ctx: OrchestratorPromptContext = { projectId: 1, projectApiKey: 'phc_x', - host: 'https://us.posthog.com', + host: HostResolution.fromApiHost('https://us.posthog.com'), }; it('points the agent at its installed task instructions', () => { diff --git a/src/lib/agent/__tests__/agent-prompt.test.ts b/src/lib/agent/__tests__/agent-prompt.test.ts index 18609e293..2ee6bfdb9 100644 --- a/src/lib/agent/__tests__/agent-prompt.test.ts +++ b/src/lib/agent/__tests__/agent-prompt.test.ts @@ -1,5 +1,6 @@ import { assemblePrompt, type PromptContext } from '@lib/agent/agent-prompt'; import type { ProgramRun } from '@lib/agent/agent-runner'; +import { HostResolution } from '@lib/host-resolution'; function makeRunDef(overrides: Partial = {}): ProgramRun { return { @@ -16,7 +17,7 @@ function makeRunDef(overrides: Partial = {}): ProgramRun { const baseCtx: PromptContext = { projectId: 42, projectApiKey: 'phc_test123', - host: 'https://app.posthog.com', + host: HostResolution.fromApiHost('https://app.posthog.com'), }; describe('assemblePrompt', () => { diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index ef64ed6ea..4f1bdbf2f 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -25,7 +25,7 @@ import { } from '@lib/wizard-session'; import { wizardAbort, WizardError } from '@utils/wizard-abort'; import { createCustomHeaders } from '@utils/custom-headers'; -import { HostResolution } from '@lib/host-resolution'; +import type { HostResolution } from '@lib/host-resolution'; import { LINTING_TOOLS } from '@lib/safe-tools'; import { createWizardToolsServer, WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import { @@ -176,7 +176,7 @@ export type AgentConfig = { workingDirectory: string; posthogMcpUrl: string; posthogApiKey: string; - posthogApiHost: string; + host: HostResolution; additionalMcpServers?: Record; detectPackageManager: PackageManagerDetector; /** Base URL for the skills server (context-mill dev or GitHub releases) */ @@ -620,16 +620,12 @@ export async function initializeAgent( logToFile('Install directory:', options.installDir); try { - // TODO: clean up in #755 - const gatewayUrl = HostResolution.fromApiHost( - config.posthogApiHost, - ).gatewayUrl; - // Configure model routing (inherited by the SDK subprocess). All model // calls route through the PostHog LLM gateway, authed with the user's // OAuth token. // Disable experimental betas (like input_examples) the gateway doesn't support. process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = 'true'; + const gatewayUrl = config.host.gatewayUrl; process.env.ANTHROPIC_BASE_URL = gatewayUrl; process.env.ANTHROPIC_AUTH_TOKEN = config.posthogApiKey; diff --git a/src/lib/agent/agent-prompt-loader.ts b/src/lib/agent/agent-prompt-loader.ts index c7f362394..34eba8abc 100644 --- a/src/lib/agent/agent-prompt-loader.ts +++ b/src/lib/agent/agent-prompt-loader.ts @@ -20,6 +20,7 @@ import type { QueuedTask, } from './runner/sequence/orchestrator/queue'; import type { ResolvedTask } from './runner/sequence/orchestrator/executor'; +import type { HostResolution } from '@lib/host-resolution'; import { DEFAULT_AGENT_MODEL } from '@lib/constants'; /** @@ -31,7 +32,7 @@ import { DEFAULT_AGENT_MODEL } from '@lib/constants'; export interface OrchestratorPromptContext { projectId: number; projectApiKey: string; - host: string; + host: HostResolution; /** Path to the framework's reference implementation (EXAMPLE.md), if available. */ examplePath?: string; /** Path to the framework's rules (COMMANDMENTS.md), if available. */ @@ -44,7 +45,7 @@ function projectContext(ctx: OrchestratorPromptContext): string { Project context: - PostHog Project ID: ${ctx.projectId} - PostHog public token: ${ctx.projectApiKey} -- PostHog Host: ${ctx.host}`; +- PostHog Host: ${ctx.host.apiHost}`; } /** Points the agent at the framework's reference integration to learn patterns from. */ diff --git a/src/lib/agent/agent-prompt.ts b/src/lib/agent/agent-prompt.ts index 0cff0d7ed..0427fc4f5 100644 --- a/src/lib/agent/agent-prompt.ts +++ b/src/lib/agent/agent-prompt.ts @@ -8,6 +8,7 @@ */ import type { ProgramRun } from './agent-runner.js'; +import type { HostResolution } from '@lib/host-resolution'; /** * Values available to prompt builders after OAuth completes. @@ -15,7 +16,7 @@ import type { ProgramRun } from './agent-runner.js'; export interface PromptContext { projectId: number; projectApiKey: string; - host: string; + host: HostResolution; /** Set when skillId was provided and the skill was installed successfully. */ skillPath?: string; /** @@ -44,7 +45,7 @@ function defaultProjectPrompt(ctx: PromptContext): string { Project context: - PostHog Project ID: ${ctx.projectId} - PostHog public token: ${ctx.projectApiKey} -- PostHog Host: ${ctx.host}`; +- PostHog Host: ${ctx.host.apiHost}`; } function skillPrompt(skillPath: string, reportFile: string): string { diff --git a/src/lib/agent/mcp-prompt-streaming.ts b/src/lib/agent/mcp-prompt-streaming.ts index cd58990b8..6066751b2 100644 --- a/src/lib/agent/mcp-prompt-streaming.ts +++ b/src/lib/agent/mcp-prompt-streaming.ts @@ -15,7 +15,6 @@ import type { AgentChunk } from '@ui/tui/services/mcp-suggested-prompts-services'; import type { Credentials } from '@lib/wizard-session'; import { DEFAULT_AGENT_MODEL, WIZARD_USER_AGENT } from '@lib/constants'; -import { HostResolution, mcpUrlFor } from '@lib/host-resolution'; import { logToFile } from '@utils/debug'; import { buildAgentEnv } from '@lib/agent/agent-interface'; import { sanitizeAgentSubprocessEnv } from '@lib/agent/agent-env-isolation'; @@ -42,13 +41,6 @@ const MODEL = DEFAULT_AGENT_MODEL; // telemetry on average turn counts per prompt. const MAX_TURNS = 30; -// One MCP url for every region: the server resolves the user's region from -// the bearer token, so the EU subdomain (a Claude Code OAuth workaround) is -// not needed here. -function resolveMcpUrl(): string { - return mcpUrlFor(false); -} - /** * Extract a short, single-line summary from an arbitrary value. Used * for tool-call args and tool-result bodies so the screen has something @@ -201,8 +193,7 @@ export async function* runMcpPromptViaSdk(args: { process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = 'true'; // Route through the PostHog LLM gateway, authed with the user's OAuth token. - // TODO: clean up in #755 - const gatewayUrl = HostResolution.fromApiHost(credentials.host).gatewayUrl; + const gatewayUrl = credentials.host.gatewayUrl; process.env.ANTHROPIC_BASE_URL = gatewayUrl; process.env.ANTHROPIC_AUTH_TOKEN = credentials.accessToken; process.env.CLAUDE_CODE_OAUTH_TOKEN = credentials.accessToken; @@ -226,7 +217,7 @@ export async function* runMcpPromptViaSdk(args: { once: true, }); - const mcpUrl = resolveMcpUrl(); + const mcpUrl = credentials.host.mcpUrl; logToFile( `[runMcpPromptViaSdk] mcpUrl=${mcpUrl} model=${MODEL} resume=${ resumeSessionId ?? '(none)' diff --git a/src/lib/agent/runner/harness/anthropic/index.ts b/src/lib/agent/runner/harness/anthropic/index.ts index 01dcd101b..043d8a23b 100644 --- a/src/lib/agent/runner/harness/anthropic/index.ts +++ b/src/lib/agent/runner/harness/anthropic/index.ts @@ -43,22 +43,16 @@ export const anthropicBackend: AgentHarness = { middleware, model, } = inputs; - const { - skillsBaseUrl, - accessToken, - host, - mcpUrl, - wizardFlags, - wizardMetadata, - } = boot; + const { skillsBaseUrl, credentials, wizardFlags, wizardMetadata } = boot; + const { accessToken, host } = credentials; getUI().log.step('Initializing Claude agent...'); const agent = await initializeAgent( { workingDirectory: session.installDir, - posthogMcpUrl: mcpUrl, + posthogMcpUrl: host.mcpUrl, posthogApiKey: accessToken, - posthogApiHost: host, + host, additionalMcpServers: config.additionalMcpServers, detectPackageManager: config.detectPackageManager ?? detectNodePackageManagers, @@ -124,9 +118,9 @@ export const anthropicBackend: AgentHarness = { const agent = await initializeAgent( { workingDirectory: session.installDir, - posthogMcpUrl: boot.mcpUrl, - posthogApiKey: boot.accessToken, - posthogApiHost: boot.host, + posthogMcpUrl: boot.credentials.host.mcpUrl, + posthogApiKey: boot.credentials.accessToken, + host: boot.credentials.host, detectPackageManager: detectNodePackageManagers, skillsBaseUrl: boot.skillsBaseUrl, wizardFlags: boot.wizardFlags, diff --git a/src/lib/agent/runner/harness/pi/index.ts b/src/lib/agent/runner/harness/pi/index.ts index d34e47472..0b6c270b3 100644 --- a/src/lib/agent/runner/harness/pi/index.ts +++ b/src/lib/agent/runner/harness/pi/index.ts @@ -16,7 +16,6 @@ import fs from 'fs'; import path from 'path'; import { getUI } from '@ui'; import { getLogFilePath, logToFile } from '@utils/debug'; -import { getLlmGatewayUrl } from '@utils/urls'; import { Harness, POSTHOG_FLAG_HEADER_PREFIX, @@ -95,16 +94,16 @@ function piMcpContext(boot: BootstrapResult, instructions?: string): string { return ['', '## PostHog MCP server', instructions].join('\n'); } const project = boot.project?.name - ? `${boot.project.name} (id ${boot.projectId})` - : `id ${boot.projectId}`; + ? `${boot.project.name} (id ${boot.credentials.projectId})` + : `id ${boot.credentials.projectId}`; // Fallback: a `## PostHog project` block with name/id, host, region. return [ '', '## PostHog project', 'Your `posthog_exec` calls run against this project:', `- Project: ${project}`, - `- Host: ${boot.host}`, - `- Region: ${boot.cloudRegion}`, + `- Host: ${boot.credentials.host.apiHost}`, + `- Region: ${boot.credentials.host.region}`, ].join('\n'); } @@ -314,14 +313,14 @@ export const piBackend: AgentHarness = { // `/v1` the Anthropic SDK strips. const api = gatewayApiFor(modelId); const caps = modelCapabilities(modelId, boot.wizardFlags); - const gatewayUrl = getLlmGatewayUrl(boot.host); + const gatewayUrl = boot.credentials.host.gatewayUrl; const baseUrl = api === 'openai-completions' ? `${gatewayUrl}/v1` : gatewayUrl; const registry = ModelRegistry.inMemory(AuthStorage.create()); registry.registerProvider(GATEWAY_PROVIDER, { name: 'PostHog Gateway', baseUrl, - apiKey: boot.accessToken, + apiKey: boot.credentials.accessToken, authHeader: true, api, headers: buildGatewayHeaders(boot.wizardMetadata, boot.wizardFlags), @@ -368,7 +367,10 @@ export const piBackend: AgentHarness = { // so it gets the bare gateway URL regardless of which API shape the // agent's model uses. Without this, pi has no ANTHROPIC_* env (it // auths programmatically) and triage would silently no-op. - triageAuth: { baseURL: gatewayUrl, authToken: boot.accessToken }, + triageAuth: { + baseURL: gatewayUrl, + authToken: boot.credentials.accessToken, + }, // Where pi's bash runs; the rm allowance is confined to this tree. workingDirectory: session.installDir, }); @@ -392,8 +394,8 @@ export const piBackend: AgentHarness = { const { setupPostHogMcp } = await import('./mcp'); const mcp = await setupPostHogMcp({ agentDir: getAgentDir(), - mcpUrl: boot.mcpUrl, - accessToken: boot.accessToken, + mcpUrl: boot.credentials.host.mcpUrl, + accessToken: boot.credentials.accessToken, userAgent: WIZARD_USER_AGENT, }); extensionFactories.push(mcp.extensionFactory); diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index 5790eec11..cb20627b5 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -10,7 +10,6 @@ import { OutroKind } from '../../../wizard-session'; import { getUI } from '../../../../ui'; import { AgentErrorType, AgentSignals } from '../../agent-interface'; import { restoreClaudeSettings } from '../../claude-settings'; -import { HostResolution } from '@lib/host-resolution'; import { logToFile } from '../../../../utils/debug'; import { createBenchmarkPipeline } from '../../../middleware/benchmark'; import { @@ -40,16 +39,8 @@ export async function runLinearProgram( boot: BootstrapResult, composed = false, ): Promise { - const { - skillsBaseUrl, - projectApiKey, - host, - accessToken, - projectId, - cloudRegion, - wizardFlags, - project, - } = boot; + const { skillsBaseUrl, credentials, wizardFlags, project } = boot; + const { projectApiKey, host, projectId } = credentials; // 5. Skill install (if skillId provided) let skillPath: string | undefined; @@ -270,12 +261,7 @@ export async function runLinearProgram( // 10. Post-run hooks if (config.postRun) { - await config.postRun(session, { - accessToken, - projectApiKey, - host, - projectId, - }); + await config.postRun(session, credentials); } // A composed sub-run (integration inside self-driving) skips the terminal @@ -291,23 +277,14 @@ export async function runLinearProgram( // that the screen never reads. UI.setOutroData() goes through the store // and also merges in any post-snapshot URLs from the live session. const outroData = config.buildOutroData - ? config.buildOutroData( - session, - { accessToken, projectApiKey, host, projectId }, - cloudRegion, - ) + ? config.buildOutroData(session, credentials) : { kind: OutroKind.Success, message: config.successMessage, reportFile: config.reportFile, docsUrl: config.docsUrl, - // TODO: clean up in #755 continueUrl: session.signup - ? `${ - HostResolution.fromRegion(cloudRegion, { - baseUrl: session.baseUrl, - }).appHost - }/products?source=wizard` + ? `${host.appHost}/products?source=wizard` : undefined, }; if (outroData) { diff --git a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts index b735d2e41..c18c41076 100644 --- a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts +++ b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts @@ -229,9 +229,9 @@ export async function runOrchestrator( // The client injects the basics (project context + the I/O contract) around // every authored agent-prompt body. const promptContext: OrchestratorPromptContext = { - projectId: boot.projectId, - projectApiKey: boot.projectApiKey, - host: boot.host, + projectId: boot.credentials.projectId, + projectApiKey: boot.credentials.projectApiKey, + host: boot.credentials.host, examplePath, commandmentsPath, }; diff --git a/src/lib/agent/runner/shared/authenticate.ts b/src/lib/agent/runner/shared/authenticate.ts index c67f5c9dc..3bc8aed98 100644 --- a/src/lib/agent/runner/shared/authenticate.ts +++ b/src/lib/agent/runner/shared/authenticate.ts @@ -29,7 +29,6 @@ export async function authenticate( host, accessToken, projectId, - cloudRegion, roleAtOrganization, user, project, @@ -41,11 +40,11 @@ export async function authenticate( email: session.email, region: session.region, baseUrl: session.baseUrl, + localMcp: session.localMcp, programId, }); session.credentials = { accessToken, projectApiKey, host, projectId }; - session.cloudRegion = cloudRegion; session.apiProject = project; session.roleAtOrganization = roleAtOrganization; session.apiUser = user; @@ -57,5 +56,5 @@ export async function authenticate( // Identify the user (email, name) before flags are evaluated, so flags can // target the individual user and not just $app_name. if (user) analytics.identifyUser(user); - analytics.setGroups(groupsFromUser(user, host)); + analytics.setGroups(groupsFromUser(user, host.apiHost)); } diff --git a/src/lib/agent/runner/shared/bootstrap.ts b/src/lib/agent/runner/shared/bootstrap.ts index 539192325..c2b4d570b 100644 --- a/src/lib/agent/runner/shared/bootstrap.ts +++ b/src/lib/agent/runner/shared/bootstrap.ts @@ -8,7 +8,6 @@ */ import type { WizardSession } from '@lib/wizard-session'; -import { mcpUrlFor } from '@lib/host-resolution'; import { analytics } from '@utils/analytics'; import { getUI } from '@ui'; import { authenticate } from './authenticate'; @@ -218,8 +217,6 @@ export async function bootstrapProgram( // the first login; it does not launch another OAuth. authenticate() also // identifies the user and sets analytics groups. await authenticate(session, programConfig.id); - const { projectApiKey, host, accessToken, projectId } = session.credentials!; - const cloudRegion = session.cloudRegion!; const project = session.apiProject; // 4.5. AI opt-in enforcement. Parks here while AiOptInRequiredScreen is @@ -248,9 +245,9 @@ export async function bootstrapProgram( } } - // Feature flags and MCP url. Both arms need these, and the fork decision reads - // the flags. This map is PostHog-side only — CLI `--harness` / `--sequence` - // precedence lives at the resolution sites (`runner/index.ts` for sequence, + // Feature flags. Both arms need these, and the fork decision reads the flags. + // This map is PostHog-side only — CLI `--harness` / `--sequence` precedence + // lives at the resolution sites (`runner/index.ts` for sequence, // `resolveHarness` for harness), not here. const wizardFlags = await analytics.getAllFlagsForWizard(); @@ -264,19 +261,12 @@ export async function bootstrapProgram( skillId: config.skillId, }); - // One MCP url for every region: the server resolves the user's region from - // the bearer token, so the EU subdomain (a Claude Code OAuth workaround) is - // not needed here. - const mcpUrl = mcpUrlFor(session.localMcp); - + // Credentials (incl. the resolved host family and its MCP url) live on + // `session.credentials`; narrow once at this boundary — `authenticate` above + // set them — so downstream readers get a non-null type without asserting. return { skillsBaseUrl, - projectApiKey, - host, - accessToken, - projectId, - cloudRegion, - mcpUrl, + credentials: session.credentials!, wizardFlags, wizardMetadata, project, diff --git a/src/lib/agent/runner/shared/types.ts b/src/lib/agent/runner/shared/types.ts index a5f724ead..0b7f82fbd 100644 --- a/src/lib/agent/runner/shared/types.ts +++ b/src/lib/agent/runner/shared/types.ts @@ -9,7 +9,6 @@ import type { } from '@lib/wizard-session'; import type { PromptContext } from '@lib/agent/agent-prompt'; import type { PackageManagerDetector } from '@lib/detection/package-manager'; -import type { CloudRegion } from '@utils/types'; import type { ApiProject } from '@lib/api'; export type { PromptContext, Credentials }; @@ -58,7 +57,6 @@ export interface ProgramRun { buildOutroData?: ( session: WizardSession, credentials: Credentials, - cloudRegion: CloudRegion | undefined, ) => WizardSession['outroData']; /** * Per-run cap on `wizard_ask` invocations. Defaults to 10. The 4th call @@ -91,17 +89,14 @@ export interface ProgramRun { /** * Result of the shared bootstrap, consumed by both the linear and the - * orchestrator arm. Credentials, role, and user are already applied to the - * session by `bootstrapProgram`; this carries the values both arms still need. + * orchestrator arm. `bootstrapProgram` runs `authenticate` before returning, so + * `credentials` is guaranteed non-null here — the single narrowing point owns + * the invariant, and downstream readers get a properly non-null type for free. */ export interface BootstrapResult { skillsBaseUrl: string; - projectApiKey: Credentials['projectApiKey']; - host: Credentials['host']; - accessToken: Credentials['accessToken']; - projectId: Credentials['projectId']; - cloudRegion: CloudRegion; - mcpUrl: string; + /** Auth outputs (incl. the resolved host family and its MCP url), narrowed at the boundary. */ + credentials: Credentials; wizardFlags: Record; wizardMetadata: Record; /** Full project payload, for project-level prompt context (opt-ins). */ diff --git a/src/lib/detection/agentic.ts b/src/lib/detection/agentic.ts index 8321e16ef..aecebb13a 100644 --- a/src/lib/detection/agentic.ts +++ b/src/lib/detection/agentic.ts @@ -250,16 +250,12 @@ export async function detectProjectsWithAgent( const cwd = session.installDir; const runOptions = sessionToWizardOptions(session); - const mcpUrl = session.localMcp - ? 'http://localhost:8787/mcp' - : 'https://mcp.posthog.com/mcp'; - const agent = await initializeAgent( { workingDirectory: cwd, - posthogMcpUrl: mcpUrl, + posthogMcpUrl: host.mcpUrl, posthogApiKey: accessToken, - posthogApiHost: host, + host, detectPackageManager: detectNodePackageManagers, skillsBaseUrl: getSkillsBaseUrl(session.localMcp), integrationLabel: 'agentic-detect', diff --git a/src/lib/programs/__tests__/agent-skill.test.ts b/src/lib/programs/__tests__/agent-skill.test.ts index c6c31f0cc..132586e1d 100644 --- a/src/lib/programs/__tests__/agent-skill.test.ts +++ b/src/lib/programs/__tests__/agent-skill.test.ts @@ -5,6 +5,7 @@ import { } from '@lib/programs/agent-skill/index'; import type { ProgramRun } from '@lib/agent/agent-runner'; import { buildSession, RunPhase } from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; const baseOpts: SkillProgramOptions = { skillId: 'error-tracking-setup', @@ -78,7 +79,7 @@ describe('AGENT_SKILL_STEPS', () => { session.credentials = { accessToken: 't', projectApiKey: 'k', - host: 'h', + host: HostResolution.fromApiHost('h'), projectId: 1, }; expect(auth.isComplete!(session)).toBe(true); diff --git a/src/lib/programs/__tests__/self-driving-prompt.test.ts b/src/lib/programs/__tests__/self-driving-prompt.test.ts index a01fe396a..ede157e25 100644 --- a/src/lib/programs/__tests__/self-driving-prompt.test.ts +++ b/src/lib/programs/__tests__/self-driving-prompt.test.ts @@ -1,10 +1,11 @@ import { buildSelfDrivingPrompt } from '@lib/programs/self-driving/prompt'; import type { PromptContext } from '@lib/agent/agent-runner'; +import { HostResolution } from '@lib/host-resolution'; const ctx: PromptContext = { projectId: 123, projectApiKey: 'phc_test', - host: 'https://us.posthog.com', + host: HostResolution.fromApiHost('https://us.posthog.com'), }; describe('buildSelfDrivingPrompt', () => { diff --git a/src/lib/programs/audit/index.ts b/src/lib/programs/audit/index.ts index 2964e0c06..b326b9056 100644 --- a/src/lib/programs/audit/index.ts +++ b/src/lib/programs/audit/index.ts @@ -7,7 +7,6 @@ import type { ProgramRun } from '@lib/agent/agent-runner'; import type { WizardSession } from '@lib/wizard-session'; import { OutroKind } from '@lib/wizard-session'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; -import { HostResolution } from '@lib/host-resolution'; import { AUDIT_ABORT_CASES } from './detect.js'; import { AUDIT_CHECKS_KEY, AUDIT_REPORT_FILE } from './types.js'; import { AUDIT_SEED_CHECKS, seedAuditLedger } from './seed.js'; @@ -68,16 +67,11 @@ const auditRun = async (session: WizardSession): Promise => { // Override the default outro so the dashboard + notebook URLs the // agent emits via `[DASHBOARD_URL]` / `[NOTEBOOK_URL]` are surfaced // on the post-run screen. - buildOutroData: (session, _credentials, cloudRegion) => { - // TODO: clean up in #755 - const cloudUrl = cloudRegion - ? HostResolution.fromRegion(cloudRegion, { baseUrl: session.baseUrl }) - .appHost + buildOutroData: (sess, credentials) => { + const cloudUrl = credentials.host.appHost; + const continueUrl = sess.signup + ? `${cloudUrl}/products?source=wizard` : undefined; - const continueUrl = - session.signup && cloudUrl - ? `${cloudUrl}/products?source=wizard` - : undefined; // Note: `sess` here is the agent-runner's snapshot of session at // runAgent() invocation time. Any URL emissions during the run land diff --git a/src/lib/programs/error-tracking-upload-source-maps/index.ts b/src/lib/programs/error-tracking-upload-source-maps/index.ts index 57c657bcf..ad009d255 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/index.ts +++ b/src/lib/programs/error-tracking-upload-source-maps/index.ts @@ -13,7 +13,6 @@ import { type SkillVariant, } from './detect.js'; import { getContentBlocks } from './content/index.js'; -import { getUiHostFromHost } from '@utils/urls'; import { getUI } from '@ui'; const REPORT_FILE = 'posthog-source-maps-report.md'; @@ -75,7 +74,7 @@ export const errorTrackingUploadSourceMapsConfig: ProgramConfig = { return SOURCE_MAPS_DETECTION_FAILED_PROMPT; } - const uiHost = getUiHostFromHost(ctx.host).replace(/\/$/, ''); + const uiHost = ctx.host.appHost.replace(/\/$/, ''); return buildSourceMapsUploadPrompt({ displayName, @@ -83,7 +82,7 @@ export const errorTrackingUploadSourceMapsConfig: ProgramConfig = { skillId, projectPath, projectId: ctx.projectId, - host: ctx.host, + host: ctx.host.apiHost, settingsUrl: `${uiHost}/project/${ctx.projectId}/settings/user-api-keys`, uiHost, }); diff --git a/src/lib/programs/events-audit/index.ts b/src/lib/programs/events-audit/index.ts index ae7044531..f6937b2b5 100644 --- a/src/lib/programs/events-audit/index.ts +++ b/src/lib/programs/events-audit/index.ts @@ -4,7 +4,6 @@ import type { WizardSession } from '@lib/wizard-session'; import { OutroKind } from '@lib/wizard-session'; import { SPINNER_MESSAGE } from '@lib/framework-config'; import { isUsingTypeScript } from '@utils/setup-utils'; -import { HostResolution } from '@lib/host-resolution'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import { EVENTS_AUDIT_PROGRAM } from './steps.js'; import { AUDIT_CHECKS_KEY } from '@lib/programs/audit/types'; @@ -63,19 +62,14 @@ Project context: - PostHog Project ID: ${ctx.projectId} - TypeScript: ${typeScriptDetected ? 'Yes' : 'No'} - PostHog public token: ${ctx.projectApiKey} -- PostHog Host: ${ctx.host} +- PostHog Host: ${ctx.host.apiHost} `, - buildOutroData: (sess, _credentials, cloudRegion) => { - // TODO: clean up in #755 - const cloudUrl = cloudRegion - ? HostResolution.fromRegion(cloudRegion, { baseUrl: sess.baseUrl }) - .appHost + buildOutroData: (sess, credentials) => { + const cloudUrl = credentials.host.appHost; + const continueUrl = sess.signup + ? `${cloudUrl}/products?source=wizard` : undefined; - const continueUrl = - sess.signup && cloudUrl - ? `${cloudUrl}/products?source=wizard` - : undefined; // The agent emits `[DASHBOARD_URL] ` once it creates the // dashboard; the SDK-message interceptor stores it on the session. // Fall back to the dashboards index if nothing was emitted. diff --git a/src/lib/programs/posthog-integration/index.ts b/src/lib/programs/posthog-integration/index.ts index 687438b43..76d7bd28c 100644 --- a/src/lib/programs/posthog-integration/index.ts +++ b/src/lib/programs/posthog-integration/index.ts @@ -15,10 +15,9 @@ import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { wizardAbort } from '@utils/wizard-abort'; import { WIZARD_INTERACTION_EVENT_NAME } from '@lib/constants'; import { getUI } from '@ui/index'; -import { HostResolution } from '@lib/host-resolution'; import { requestDeepLink } from '@utils/provisioning'; import { openTrackedLink, withUtm } from '@utils/links'; -import type { CloudRegion } from '@utils/types'; +import type { HostResolution } from '@lib/host-resolution'; import { POSTHOG_INTEGRATION_PROGRAM } from './steps.js'; import { getContentBlocks } from './content/index.js'; import { buildCodingAgentPrompt } from './handoff.js'; @@ -26,22 +25,13 @@ import { buildCodingAgentPrompt } from './handoff.js'; const DASHBOARD_DEEP_LINK_KEY = 'dashboardDeepLink'; function resolveContinueUrl( - session: WizardSession, - cloudRegion: CloudRegion | undefined, + sess: WizardSession, + host: HostResolution, deepLink: unknown, ): string | undefined { - if (!session.signup) return undefined; + if (!sess.signup) return undefined; if (typeof deepLink === 'string' && deepLink) return deepLink; - if (cloudRegion) - // TODO: clean up in #755 - return withUtm( - `${ - HostResolution.fromRegion(cloudRegion, { baseUrl: session.baseUrl }) - .appHost - }/products?source=wizard`, - 'outro-continue', - ); - return undefined; + return withUtm(`${host.appHost}/products?source=wizard`, 'outro-continue'); } export const SETUP_REPORT_FILE = 'posthog-setup-report.md'; @@ -168,7 +158,7 @@ Project context: - Framework: ${config.metadata.name} ${frameworkVersion || 'latest'} - TypeScript: ${typeScriptDetected ? 'Yes' : 'No'} - PostHog public token: ${ctx.projectApiKey} -- PostHog Host: ${ctx.host} +- PostHog Host: ${ctx.host.apiHost} - Project type: ${config.prompts.projectTypeDetection} - Package installation: ${ config.prompts.packageInstallation ?? DEFAULT_PACKAGE_INSTALLATION @@ -214,7 +204,7 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server postRun: async (sess, credentials) => { const envVars = config.environment.getEnvVars( credentials.projectApiKey, - credentials.host, + credentials.host.apiHost, ); if (config.environment.uploadToHosting) { const { uploadEnvironmentVariablesStep } = await import( @@ -252,13 +242,17 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server } }, - buildOutroData: (sess, credentials, cloudRegion) => { + buildOutroData: (sess, credentials) => { const envVars = config.environment.getEnvVars( credentials.projectApiKey, - credentials.host, + credentials.host.apiHost, ); const deepLink = sess.frameworkContext[DASHBOARD_DEEP_LINK_KEY]; - const continueUrl = resolveContinueUrl(sess, cloudRegion, deepLink); + const continueUrl = resolveContinueUrl( + sess, + credentials.host, + deepLink, + ); const changes = [ ...config.ui.getOutroChanges(frameworkContext), diff --git a/src/lib/programs/self-driving/index.ts b/src/lib/programs/self-driving/index.ts index e75686db2..5845ffb7a 100644 --- a/src/lib/programs/self-driving/index.ts +++ b/src/lib/programs/self-driving/index.ts @@ -3,7 +3,6 @@ import { access, rm } from 'node:fs/promises'; import type { ProgramConfig } from '@lib/programs/program-step'; import type { ProgramRun } from '@lib/agent/agent-runner'; import { OutroKind } from '@lib/wizard-session'; -import { getUiHostFromHost } from '@utils/urls'; import { createSkillProgram } from '../agent-skill/index.js'; import { SELF_DRIVING_PROGRAM } from './steps.js'; import { SELF_DRIVING_ABORT_CASES } from './detect.js'; @@ -72,7 +71,7 @@ const run: ProgramRun = { }, buildOutroData: (_session, credentials) => { - const uiHost = getUiHostFromHost(credentials.host).replace(/\/$/, ''); + const uiHost = credentials.host.appHost.replace(/\/$/, ''); const inboxUrl = `${uiHost}/project/${credentials.projectId}/inbox`; return { kind: OutroKind.Success as const, diff --git a/src/lib/programs/self-driving/prompt.ts b/src/lib/programs/self-driving/prompt.ts index 824aedb37..78d6900f6 100644 --- a/src/lib/programs/self-driving/prompt.ts +++ b/src/lib/programs/self-driving/prompt.ts @@ -1,6 +1,5 @@ import { AgentSignals } from '@lib/agent/agent-interface'; import type { PromptContext } from '@lib/agent/agent-runner'; -import { getUiHostFromHost } from '@utils/urls'; /** * Build the self-driving run prompt. The installed @@ -14,7 +13,7 @@ import { getUiHostFromHost } from '@utils/urls'; * list — so this prompt only covers the Self-driving steps. */ export function buildSelfDrivingPrompt(ctx: PromptContext): string { - const uiHost = getUiHostFromHost(ctx.host).replace(/\/$/, ''); + const uiHost = ctx.host.appHost.replace(/\/$/, ''); const projectBase = `${uiHost}/project/${ctx.projectId}`; const integrationsSettingsUrl = `${projectBase}/settings/environment-integrations`; const orgAiSettingsUrl = `${uiHost}/settings/organization#organization-ai-consent`; diff --git a/src/lib/task-stream/__tests__/posthog-destination.test.ts b/src/lib/task-stream/__tests__/posthog-destination.test.ts index 266b18b32..3f5945456 100644 --- a/src/lib/task-stream/__tests__/posthog-destination.test.ts +++ b/src/lib/task-stream/__tests__/posthog-destination.test.ts @@ -1,9 +1,10 @@ import { PostHogDestination } from '../destinations/posthog'; import { StreamEvent, type TaskStreamUpdate } from '../types'; import { RunPhase, type Credentials } from '../../wizard-session'; +import { HostResolution } from '../../host-resolution'; const SAMPLE_CREDS: Credentials = { - host: 'https://us.posthog.com', + host: HostResolution.fromApiHost('https://us.posthog.com'), projectId: 42, accessToken: 'pha_abc', projectApiKey: 'phc_test', @@ -277,7 +278,7 @@ describe('PostHogDestination', () => { const dest = new PostHogDestination({ getCredentials: () => ({ ...SAMPLE_CREDS, - host: 'https://us.posthog.com/', + host: HostResolution.fromApiHost('https://us.posthog.com/'), }), fetchImpl, }); diff --git a/src/lib/task-stream/destinations/posthog.ts b/src/lib/task-stream/destinations/posthog.ts index affcf39e1..f40e6c925 100644 --- a/src/lib/task-stream/destinations/posthog.ts +++ b/src/lib/task-stream/destinations/posthog.ts @@ -106,7 +106,7 @@ export class PostHogDestination implements TaskStreamDestination { creds: Credentials, body: object, ): { url: string; init: Parameters[1] } { - const url = `${creds.host.replace(/\/$/, '')}/api/projects/${ + const url = `${creds.host.apiHost.replace(/\/$/, '')}/api/projects/${ creds.projectId }/wizard/sessions/`; return { diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index de6462479..7eaf8b1b1 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -15,11 +15,13 @@ import type { FrameworkConfig } from './framework-config'; import type { WizardReadinessResult } from './health-checks/readiness'; import type { SettingsConflict } from './agent/claude-settings'; import type { ApiUser, ApiProject } from './api'; +import type { HostResolution } from './host-resolution'; export interface Credentials { accessToken: string; projectApiKey: string; - host: string; + /** Resolved at auth time and immutable thereafter — see {@link HostResolution}. */ + host: HostResolution; projectId: number; } @@ -241,11 +243,11 @@ export interface WizardSession { apiUser: ApiUser | null; /** - * Cloud region and project payload resolved at authentication, kept so a - * second agent run in the same invocation (e.g. self-driving's integration - * phase) reuses the first login wholesale instead of re-authenticating. + * Project payload resolved at authentication, kept so a second agent run in + * the same invocation (e.g. self-driving's integration phase) reuses the + * first login wholesale instead of re-authenticating. The resolved region + * lives on `credentials.host.region`. */ - cloudRegion: CloudRegion | null; apiProject: ApiProject | null; // Lifecycle @@ -407,7 +409,6 @@ export function buildSession(args: { credentials: null, roleAtOrganization: null, apiUser: null, - cloudRegion: null, apiProject: null, readinessResult: null, outageDismissed: false, diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index b3206f64c..cdee840ce 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/logging-ui.ts @@ -21,6 +21,7 @@ import { } from '@lib/health-checks/readiness'; import type { AskAnswers, + Credentials, OutroData, PendingQuestion, } from '@lib/wizard-session'; @@ -217,12 +218,7 @@ export class LoggingUI implements WizardUI { // No-op in CI mode } - setCredentials(_credentials: { - accessToken: string; - projectApiKey: string; - host: string; - projectId: number; - }): void { + setCredentials(_credentials: Credentials): void { // No-op in CI mode — credentials are handled directly } diff --git a/src/ui/tui/__tests__/router.test.ts b/src/ui/tui/__tests__/router.test.ts index a5c38bf32..cad6ef357 100644 --- a/src/ui/tui/__tests__/router.test.ts +++ b/src/ui/tui/__tests__/router.test.ts @@ -1,4 +1,5 @@ import { buildSession, McpOutcome, RunPhase } from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; import { WizardReadiness } from '@lib/health-checks/readiness'; import { WizardRouter, ScreenId, Overlay, Program } from '@ui/tui/router'; import { Integration } from '@lib/constants'; @@ -25,7 +26,7 @@ describe('WizardRouter', () => { session.credentials = { accessToken: 'tok', projectApiKey: 'pk', - host: 'https://app.posthog.com', + host: HostResolution.fromApiHost('https://app.posthog.com'), projectId: 1, }; @@ -67,7 +68,7 @@ describe('WizardRouter', () => { session.credentials = { accessToken: 'tok', projectApiKey: 'pk', - host: 'https://app.posthog.com', + host: HostResolution.fromApiHost('https://app.posthog.com'), projectId: 1, }; session.runPhase = RunPhase.Completed; @@ -227,7 +228,7 @@ describe('WizardRouter', () => { session.credentials = { accessToken: 'tok', projectApiKey: 'pk', - host: 'https://app.posthog.com', + host: HostResolution.fromApiHost('https://app.posthog.com'), projectId: 1, }; return session; diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index a146f0ef7..ff4ff7f58 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -15,6 +15,7 @@ import { evaluateWizardReadiness, } from '@lib/health-checks/readiness'; import { buildSession } from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; import { Integration } from '@lib/constants'; import { analytics } from '@utils/analytics'; @@ -187,7 +188,7 @@ describe('WizardStore', () => { const creds = { accessToken: 'tok', projectApiKey: 'pk', - host: 'https://app.posthog.com', + host: HostResolution.fromApiHost('https://app.posthog.com'), projectId: 42, }; store.setCredentials(creds); @@ -316,7 +317,7 @@ describe('WizardStore', () => { store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', - host: 'h', + host: HostResolution.fromApiHost('h'), projectId: 42, }); expect(wizardCaptureMock).toHaveBeenCalledWith('auth complete', { @@ -412,7 +413,7 @@ describe('WizardStore', () => { store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', - host: 'h', + host: HostResolution.fromApiHost('h'), projectId: 1, }); expect(store.currentScreen).toBe(ScreenId.Run); @@ -429,7 +430,7 @@ describe('WizardStore', () => { store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', - host: 'h', + host: HostResolution.fromApiHost('h'), projectId: 1, }); store.setRunPhase(RunPhase.Completed); @@ -447,7 +448,7 @@ describe('WizardStore', () => { store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', - host: 'h', + host: HostResolution.fromApiHost('h'), projectId: 1, }); store.setRunPhase(RunPhase.Completed); @@ -466,7 +467,7 @@ describe('WizardStore', () => { store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', - host: 'h', + host: HostResolution.fromApiHost('h'), projectId: 1, }); store.setRunPhase(RunPhase.Completed); @@ -1016,7 +1017,7 @@ describe('WizardStore', () => { // -> settings-override (overlay still on top) accessToken: 'tok', projectApiKey: 'pk', - host: 'h', + host: HostResolution.fromApiHost('h'), projectId: 1, }); store.popOverlay(); // -> health-check (readinessResult still null) @@ -1161,7 +1162,7 @@ describe('WizardStore', () => { store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', - host: 'h', + host: HostResolution.fromApiHost('h'), projectId: 1, }); store.setRunPhase(RunPhase.Error); @@ -1216,7 +1217,7 @@ describe('WizardStore', () => { store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', - host: 'https://app.posthog.com', + host: HostResolution.fromApiHost('https://app.posthog.com'), projectId: 1, }); expect(store.currentScreen).toBe(ScreenId.Run); @@ -1265,7 +1266,7 @@ describe('WizardStore', () => { store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', - host: 'https://app.posthog.com', + host: HostResolution.fromApiHost('https://app.posthog.com'), projectId: 1, }); expect(store.currentScreen).toBe(ScreenId.Run); @@ -1303,7 +1304,7 @@ describe('WizardStore', () => { store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', - host: 'https://app.posthog.com', + host: HostResolution.fromApiHost('https://app.posthog.com'), projectId: 1, }); expect(store.currentScreen).toBe(ScreenId.Run); @@ -1417,7 +1418,7 @@ describe('WizardStore', () => { store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', - host: 'h', + host: HostResolution.fromApiHost('h'), projectId: 1, }); wizardCaptureMock.mockClear(); diff --git a/src/ui/tui/ink-ui.ts b/src/ui/tui/ink-ui.ts index 598d66cca..2884b5e81 100644 --- a/src/ui/tui/ink-ui.ts +++ b/src/ui/tui/ink-ui.ts @@ -18,6 +18,7 @@ import type { WizardReadinessResult } from '@lib/health-checks/readiness'; import type { ApiUser } from '@lib/api'; import type { AskAnswers, + Credentials, OutroData, PendingQuestion, } from '@lib/wizard-session'; @@ -81,12 +82,7 @@ export class InkUI implements WizardUI { }); } - setCredentials(credentials: { - accessToken: string; - projectApiKey: string; - host: string; - projectId: number; - }): void { + setCredentials(credentials: Credentials): void { this.store.setCredentials(credentials); } diff --git a/src/ui/tui/playground/demos/AiOptInDemo.tsx b/src/ui/tui/playground/demos/AiOptInDemo.tsx index 6e899d465..97897e219 100644 --- a/src/ui/tui/playground/demos/AiOptInDemo.tsx +++ b/src/ui/tui/playground/demos/AiOptInDemo.tsx @@ -16,6 +16,7 @@ import { useEffect, useState } from 'react'; import { Box, Text } from 'ink'; import { WizardStore } from '@ui/tui/store'; import { AiOptInRequiredScreen } from '@ui/tui/screens/AiOptInRequiredScreen'; +import { HostResolution } from '@lib/host-resolution'; type Variant = 'admin' | 'non-admin'; @@ -29,7 +30,7 @@ export const AiOptInDemo = ({ variant }: AiOptInDemoProps) => { s.setCredentials({ accessToken: 'demo-fake-token', projectApiKey: 'demo-fake-project-key', - host: 'https://us.posthog.com', + host: HostResolution.fromApiHost('https://us.posthog.com'), projectId: 0, }); return s; diff --git a/src/ui/tui/playground/demos/EndScreensDemo.tsx b/src/ui/tui/playground/demos/EndScreensDemo.tsx index 025b3850e..7fc165ba6 100644 --- a/src/ui/tui/playground/demos/EndScreensDemo.tsx +++ b/src/ui/tui/playground/demos/EndScreensDemo.tsx @@ -27,6 +27,7 @@ import { SlackConnectScreen } from '@ui/tui/screens/SlackConnectScreen'; import { OutroScreen } from '@ui/tui/screens/OutroScreen'; import { Colors } from '@ui/tui/styles'; import { OutroKind, type OutroData } from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; const VIEWS = ['slack-connect', 'outro'] as const; type View = (typeof VIEWS)[number]; @@ -87,7 +88,7 @@ export const EndScreensDemo = ({ store }: EndScreensDemoProps) => { store.setCredentials({ accessToken: 'playground', projectApiKey: 'playground', - host: 'http://127.0.0.1:9', + host: HostResolution.fromApiHost('http://127.0.0.1:9'), projectId: 0, }); return () => { diff --git a/src/ui/tui/playground/demos/McpSuggestedPromptsDemo.tsx b/src/ui/tui/playground/demos/McpSuggestedPromptsDemo.tsx index cf7ab08fb..dcd884b4a 100644 --- a/src/ui/tui/playground/demos/McpSuggestedPromptsDemo.tsx +++ b/src/ui/tui/playground/demos/McpSuggestedPromptsDemo.tsx @@ -31,6 +31,7 @@ import { McpSuggestedPromptsScreen } from '@ui/tui/screens/McpSuggestedPromptsSc import { Colors } from '@ui/tui/styles'; import { Integration } from '@lib/constants'; import { McpOutcome } from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; import { TAILORED_ROLES } from '@lib/mcp-role-prompts'; import type { AgentChunk, @@ -162,7 +163,7 @@ function createMockServices( credentials: { accessToken: 'phx_mock', projectApiKey: 'phc_mock', - host: 'http://127.0.0.1:1', + host: HostResolution.fromApiHost('http://127.0.0.1:1'), projectId: 1, }, roleAtOrganization: cfg.role, diff --git a/src/ui/tui/playground/start-playground.ts b/src/ui/tui/playground/start-playground.ts index 051b9b86c..1d59c495e 100644 --- a/src/ui/tui/playground/start-playground.ts +++ b/src/ui/tui/playground/start-playground.ts @@ -6,6 +6,7 @@ import { render } from 'ink'; import { createElement } from 'react'; import { WizardStore } from '@ui/tui/store'; import { PlaygroundApp } from './PlaygroundApp.js'; +import { HostResolution } from '@lib/host-resolution'; import { WizardReadiness } from '@lib/health-checks/readiness'; import { enterDarkTerminal, releaseTerminal } from '../terminal.js'; @@ -28,7 +29,7 @@ export function startPlayground(version: string): void { store.setCredentials({ accessToken: 'fake', projectApiKey: 'fake', - host: 'https://app.posthog.com', + host: HostResolution.fromApiHost('https://app.posthog.com'), projectId: 0, }); diff --git a/src/ui/tui/screens/AiOptInRequiredScreen.tsx b/src/ui/tui/screens/AiOptInRequiredScreen.tsx index f8d9792db..36d32ea46 100644 --- a/src/ui/tui/screens/AiOptInRequiredScreen.tsx +++ b/src/ui/tui/screens/AiOptInRequiredScreen.tsx @@ -21,7 +21,6 @@ import { useKeyBindings } from '@ui/tui/hooks/useKeyBindings'; import { Colors } from '@ui/tui/styles'; import { useSkillEntry } from '@ui/tui/screens/SkillSourceInfo'; import { fetchUserData } from '@lib/api'; -import { HostResolution } from '@lib/host-resolution'; import { CONTEXT_MILL_RELEASES_URL, POSTHOG_APP_URL } from '@lib/constants'; import { analytics } from '@utils/analytics'; import { LoadingBox } from '@ui/tui/primitives/index'; @@ -47,7 +46,6 @@ export const AiOptInRequiredScreen = ({ const isAdmin = (org?.membership_level ?? 0) >= ORG_ADMIN_LEVEL; const variant: 'admin' | 'non-admin' = isAdmin ? 'admin' : 'non-admin'; - const region = session.region ?? 'us'; const projectId = session.credentials?.projectId; // Use the region-agnostic app.posthog.com URL so the redirect routes // the user to their actual region (us / eu) based on their profile. @@ -91,18 +89,14 @@ export const AiOptInRequiredScreen = ({ const handleRetry = () => { analytics.wizardCapture('ai opt-in action', { variant, action: 'retry' }); - const accessToken = session.credentials?.accessToken; - if (!accessToken) { + const credentials = session.credentials; + if (!credentials?.accessToken) { setRetryError('Missing credentials — cannot retry.'); return; } setRetrying(true); setRetryError(null); - // TODO: clean up in #755 - void fetchUserData( - accessToken, - HostResolution.fromRegion(region, { baseUrl: session.baseUrl }).appHost, - ) + void fetchUserData(credentials.accessToken, credentials.host.appHost) .then((user) => { store.setApiUser(user); }) diff --git a/src/ui/tui/screens/SlackConnectScreen.tsx b/src/ui/tui/screens/SlackConnectScreen.tsx index 9db8f74d2..f0f4d99c8 100644 --- a/src/ui/tui/screens/SlackConnectScreen.tsx +++ b/src/ui/tui/screens/SlackConnectScreen.tsx @@ -133,7 +133,7 @@ export const SlackConnectScreen = ({ store }: SlackConnectScreenProps) => { fetchSlackConnected( credentials.accessToken, credentials.projectId, - credentials.host, + credentials.host.apiHost, controller.signal, ) .then((isConnected) => { diff --git a/src/ui/tui/screens/doctor/DoctorReportScreen.tsx b/src/ui/tui/screens/doctor/DoctorReportScreen.tsx index 4c5d5fe15..eb4a982de 100644 --- a/src/ui/tui/screens/doctor/DoctorReportScreen.tsx +++ b/src/ui/tui/screens/doctor/DoctorReportScreen.tsx @@ -7,7 +7,6 @@ import { fetchHealthIssues, type HealthIssue, } from '@lib/programs/posthog-doctor/index'; -import { getUiHostFromHost } from '@utils/urls'; import { OutroKind } from '@lib/wizard-session'; import { ApiError } from '@lib/api'; import { POSTHOG_DOCS_URL } from '@lib/constants'; @@ -30,17 +29,17 @@ export const DoctorReportScreen = ({ store }: DoctorReportScreenProps) => { const { credentials } = store.session; const accessToken = credentials?.accessToken; - const host = credentials?.host; + const apiHost = credentials?.host.apiHost; const projectId = credentials?.projectId; const [state, setState] = useState({ kind: 'loading' }); useEffect(() => { - if (!accessToken || !host || projectId == null) return; + if (!accessToken || !apiHost || projectId == null) return; let cancelled = false; void (async () => { try { - const issues = await fetchHealthIssues(accessToken, host, projectId); + const issues = await fetchHealthIssues(accessToken, apiHost, projectId); if (!cancelled) { setState({ kind: 'ready', issues }); } @@ -59,7 +58,7 @@ export const DoctorReportScreen = ({ store }: DoctorReportScreenProps) => { return () => { cancelled = true; }; - }, [accessToken, host, projectId]); + }, [accessToken, apiHost, projectId]); if (!credentials) { return ; @@ -68,20 +67,24 @@ export const DoctorReportScreen = ({ store }: DoctorReportScreenProps) => { if (state.kind === 'loading') { return ( -
+
); } - const healthUrl = `${getUiHostFromHost(credentials.host)}/project/${ - credentials.projectId - }/health`; + const healthUrl = `${credentials.host.appHost}/project/${credentials.projectId}/health`; if (state.kind === 'error') { return ( -
+
{Icons.squareFilled} Failed to fetch health issues @@ -108,7 +111,10 @@ export const DoctorReportScreen = ({ store }: DoctorReportScreenProps) => { if (issues.length === 0) { return ( -
+
{Icons.check} No active issues — you're all set! @@ -131,7 +137,10 @@ export const DoctorReportScreen = ({ store }: DoctorReportScreenProps) => { return ( -
+
{formatSummaryLine(issues)} diff --git a/src/ui/wizard-ui.ts b/src/ui/wizard-ui.ts index 155a41f50..76974506e 100644 --- a/src/ui/wizard-ui.ts +++ b/src/ui/wizard-ui.ts @@ -11,6 +11,7 @@ import type { SettingsConflict } from '@lib/agent/claude-settings'; import type { WizardReadinessResult } from '@lib/health-checks/readiness'; import type { ApiUser } from '@lib/api'; +import type { Credentials } from '@lib/wizard-session'; import type { AskAnswers, OutroData, @@ -115,12 +116,7 @@ export interface WizardUI { startRun(): void; /** Store OAuth/API credentials. Resolves past AuthScreen in TUI. */ - setCredentials(credentials: { - accessToken: string; - projectApiKey: string; - host: string; - projectId: number; - }): void; + setCredentials(credentials: Credentials): void; /** * Persist the user's `role_at_organization` once it's been fetched from diff --git a/src/utils/__tests__/ci-region.test.ts b/src/utils/__tests__/ci-region.test.ts index f2d0b43b2..17bac5029 100644 --- a/src/utils/__tests__/ci-region.test.ts +++ b/src/utils/__tests__/ci-region.test.ts @@ -62,7 +62,7 @@ describe('getOrAskForProjectData CI region', () => { 123, 'https://eu.posthog.com', ); - expect(result.cloudRegion).toBe('eu'); + expect(result.host.region).toBe('eu'); }); it('falls back to detection only when no region is provided', async () => { diff --git a/src/utils/provisioning.ts b/src/utils/provisioning.ts index 779b030a3..e5c5b8c88 100644 --- a/src/utils/provisioning.ts +++ b/src/utils/provisioning.ts @@ -19,6 +19,7 @@ import { import { resolveBaseUrl } from './urls'; import { logToFile } from './debug'; import { analytics } from './analytics'; +import type { HostResolution } from '@lib/host-resolution'; const API_VERSION = '0.1d'; @@ -245,13 +246,11 @@ export async function provisionNewAccount( */ export async function requestDeepLink( accessToken: string, - host: string, + host: HostResolution, opts?: { purpose?: string; path?: string }, ): Promise { try { - const baseUrl = host - .replace('us.i.posthog.com', 'us.posthog.com') - .replace('eu.i.posthog.com', 'eu.posthog.com'); + const baseUrl = host.appHost; const res = await axios.post( `${baseUrl}/api/agentic/provisioning/deep_links`, diff --git a/src/utils/setup-utils.ts b/src/utils/setup-utils.ts index eaa422650..415afbecf 100644 --- a/src/utils/setup-utils.ts +++ b/src/utils/setup-utils.ts @@ -14,11 +14,7 @@ import { } from './package-manager'; import type { CloudRegion, WizardRunOptions } from './types'; import { getDeclaredVersion } from './package-json'; -import { - DEFAULT_HOST_URL, - DUMMY_PROJECT_API_KEY, - ISSUES_URL, -} from '@lib/constants'; +import { DUMMY_PROJECT_API_KEY, ISSUES_URL } from '@lib/constants'; import { getOAuthScopesForProgram } from '@lib/oauth/program-scopes'; import type { ProgramId } from '@lib/programs/program-registry'; import { analytics } from './analytics'; @@ -39,7 +35,7 @@ import { wizardAbort } from './wizard-abort'; interface ProjectData { projectApiKey: string; accessToken: string; - host: string; + host: HostResolution; distinctId: string; projectId: number; /** @@ -400,17 +396,18 @@ export async function getOrAskForProjectData( /** Explicit base URL override (`--base-url`, from `session.baseUrl`). When * set, pins every PostHog origin and bypasses region resolution. */ baseUrl?: string; + /** `--local-mcp`: forwarded into the resolved host so `host.mcpUrl` is local. */ + localMcp?: boolean; /** Optional — picks the OAuth scope set via * `getOAuthScopesForProgram`. Omitted → default * `WIZARD_OAUTH_SCOPES`. Threaded into `askForWizardLogin`. */ programId?: ProgramId | null; }, ): Promise<{ - host: string; + host: HostResolution; projectApiKey: string; accessToken: string; projectId: number; - cloudRegion: CloudRegion; roleAtOrganization: string | null; user: ApiUser | null; project: ApiProject | null; @@ -419,14 +416,12 @@ export async function getOrAskForProjectData( if (_options.ci && _options.apiKey) { getUI().log.info('Using provided API key (CI mode - OAuth bypassed)'); - const hosts = await HostResolution.fromAccessToken(_options.apiKey, { + const host = await HostResolution.fromAccessToken(_options.apiKey, { region: _options.region, + localMcp: _options.localMcp, baseUrl: _options.baseUrl, }); - - const cloudRegion = hosts.region; - const host = hosts.apiHost; - const cloudUrl = hosts.appHost; + const cloudUrl = host.appHost; const projectData = _options.projectId != null @@ -467,7 +462,6 @@ export async function getOrAskForProjectData( projectApiKey: projectData.api_token, accessToken: _options.apiKey, projectId: projectData.id, - cloudRegion, roleAtOrganization, user, project: projectData.project, @@ -479,7 +473,6 @@ export async function getOrAskForProjectData( projectApiKey, accessToken, projectId, - cloudRegion, roleAtOrganization, user, project, @@ -491,14 +484,12 @@ export async function getOrAskForProjectData( baseUrl: _options.baseUrl, programId: _options.programId, projectId: _options.projectId, + localMcp: _options.localMcp, }), ); if (!projectApiKey) { - // TODO: clean up in #755 - const cloudUrl = HostResolution.fromRegion(cloudRegion, { - baseUrl: _options.baseUrl, - }).appHost; + const cloudUrl = host.appHost; getUI().log.error(`Didn't receive a project token. This shouldn't happen :( Please let us know if you think this is a bug in the wizard: @@ -512,10 +503,9 @@ ${cloudUrl}/settings/project#variables`); return { accessToken, - host: host || DEFAULT_HOST_URL, + host, projectApiKey: projectApiKey || DUMMY_PROJECT_API_KEY, projectId, - cloudRegion, roleAtOrganization: roleAtOrganization ?? null, user: user ?? null, project: project ?? null, @@ -568,12 +558,15 @@ async function askForWizardLogin(options: { /** `--project-id`, if passed. When the user granted access to it on the consent * screen we use it directly; otherwise we fall back to the first granted team. */ projectId?: number; -}): Promise { + /** `--local-mcp`: forwarded into the resolved host so `host.mcpUrl` is local. */ + localMcp?: boolean; +}): Promise { if (options.signup) { return askForProvisioningSignup( options.email, options.region, options.baseUrl, + options.localMcp, ); } @@ -619,13 +612,14 @@ async function askForWizardLogin(options: { await abort(); } - const hosts = await HostResolution.fromAccessToken( + const host = await HostResolution.fromAccessToken( tokenResponse.access_token, - { baseUrl: options.baseUrl }, + { + localMcp: options.localMcp, + baseUrl: options.baseUrl, + }, ); - const cloudRegion = hosts.region; - const cloudUrl = hosts.appHost; - const host = hosts.apiHost; + const cloudUrl = host.appHost; const projectData = await fetchProjectData( tokenResponse.access_token, @@ -640,7 +634,6 @@ async function askForWizardLogin(options: { host, distinctId: userData.distinct_id, projectId: projectId!, - cloudRegion, roleAtOrganization: userData.role_at_organization ?? null, user: userData, project: projectData, @@ -657,7 +650,8 @@ async function askForProvisioningSignup( email?: string, region?: CloudRegion, baseUrl?: string, -): Promise { + localMcp?: boolean, +): Promise { if (!email || !email.includes('@')) { getUI().log.error( 'Email is required for signup. Use --email your@email.com with --signup.', @@ -681,8 +675,7 @@ async function askForProvisioningSignup( spinner.stop('Account created!'); getUI().log.success('Welcome to PostHog!'); - const host = result.host; - const cloudRegion: CloudRegion = host.includes('eu.') ? 'eu' : 'us'; + const host = HostResolution.fromApiHost(result.host, { localMcp }); analytics.setTag('provisioning-signup', true); @@ -692,7 +685,6 @@ async function askForProvisioningSignup( host, distinctId: email, projectId: parseInt(result.projectId, 10) || 0, - cloudRegion, }; } catch (error) { spinner.stop('Account creation failed.'); @@ -703,7 +695,7 @@ async function askForProvisioningSignup( 'This email already has a PostHog account. Switching to login flow...', ); - return askForWizardLogin({ signup: false, baseUrl }); + return askForWizardLogin({ signup: false, baseUrl, localMcp }); } getUI().log.error(`Failed to create account: ${message}`);