From e8f30541ae4e15d88a77daeca7636a2b539bebc4 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Mon, 29 Jun 2026 20:29:21 -0400 Subject: [PATCH 1/3] feat(self-driving): integrate first when the project has no PostHog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the project has no PostHog, self-driving sets it up first, then runs Self-driving — composed as steps in one program: - After auth, the detect screen scans the repo and the user picks which project to set PostHog up in (a monorepo can have several). - posthog-integration exports a self-contained run step; self-driving imports it and runs it in the picked project's dir, then a handoff screen, then its own run step. - The runner walks the step list and runs each run step's own agent. One login: the bootstrap calls an idempotent authenticate() the second run reuses. Co-Authored-By: Claude Opus 4.8 --- src/__tests__/cli.test.ts | 6 + src/__tests__/provision-cli.test.ts | 6 + src/commands/self-driving.ts | 6 + src/lib/agent/runner/shared/authenticate.ts | 61 +++++ src/lib/agent/runner/shared/bootstrap.ts | 45 +--- .../__tests__/self-driving-detect.test.ts | 50 +++- .../__tests__/self-driving-prompt.test.ts | 21 ++ src/lib/programs/posthog-integration/index.ts | 23 +- src/lib/programs/program-step.ts | 24 ++ .../programs/self-driving/detect-agentic.ts | 138 ++++++++++ src/lib/programs/self-driving/detect.ts | 49 +++- src/lib/programs/self-driving/prompt.ts | 4 + src/lib/programs/self-driving/steps.ts | 93 ++++++- src/lib/runners/run-wizard.ts | 63 ++++- src/lib/wizard-session.ts | 43 ++- src/ui/tui/__tests__/router.test.ts | 66 +++++ src/ui/tui/__tests__/store.test.ts | 24 ++ src/ui/tui/screen-registry.tsx | 10 + src/ui/tui/screen-sequences.ts | 3 + .../tui/screens/SelfDrivingHandoffScreen.tsx | 74 +++++ .../SelfDrivingIntegrationCheckScreen.tsx | 50 ++++ .../SelfDrivingIntegrationDetectScreen.tsx | 254 ++++++++++++++++++ src/ui/tui/store.ts | 57 ++++ vitest.config.ts | 2 + 24 files changed, 1118 insertions(+), 54 deletions(-) create mode 100644 src/lib/agent/runner/shared/authenticate.ts create mode 100644 src/lib/programs/__tests__/self-driving-prompt.test.ts create mode 100644 src/lib/programs/self-driving/detect-agentic.ts create mode 100644 src/ui/tui/screens/SelfDrivingHandoffScreen.tsx create mode 100644 src/ui/tui/screens/SelfDrivingIntegrationCheckScreen.tsx create mode 100644 src/ui/tui/screens/SelfDrivingIntegrationDetectScreen.tsx diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 97ade07c..6fccd7be 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -68,6 +68,12 @@ vi.mock('../lib/programs/posthog-integration/index', () => ({ steps: [], run: null, }, + integrationRunStep: { + id: 'run', + label: 'Integration', + screenId: 'run', + run: () => Promise.resolve(), + }, })); vi.mock('../utils/environment', () => ({ isNonInteractiveEnvironment: () => false, diff --git a/src/__tests__/provision-cli.test.ts b/src/__tests__/provision-cli.test.ts index 355a66ec..d1b561ba 100644 --- a/src/__tests__/provision-cli.test.ts +++ b/src/__tests__/provision-cli.test.ts @@ -33,6 +33,12 @@ vi.mock('../lib/programs/posthog-integration/index', () => ({ steps: [], run: null, }, + integrationRunStep: { + id: 'run', + label: 'Integration', + screenId: 'run', + run: () => Promise.resolve(), + }, })); vi.mock('../utils/environment', () => ({ isNonInteractiveEnvironment: () => false, diff --git a/src/commands/self-driving.ts b/src/commands/self-driving.ts index 9b201758..effa5cbf 100644 --- a/src/commands/self-driving.ts +++ b/src/commands/self-driving.ts @@ -8,6 +8,12 @@ export const selfDrivingCommand: Command = { description: selfDrivingConfig.description, options: { ...skillProgramOptions, + integrate: { + describe: + 'Integrate the PostHog SDK first, then set up Self-driving — skips the "do you already have PostHog?" question. Use when the project isn\'t set up yet.', + type: 'boolean', + default: false, + }, ...(selfDrivingConfig.cliOptions ?? {}), }, check: (argv) => { diff --git a/src/lib/agent/runner/shared/authenticate.ts b/src/lib/agent/runner/shared/authenticate.ts new file mode 100644 index 00000000..c67f5c9d --- /dev/null +++ b/src/lib/agent/runner/shared/authenticate.ts @@ -0,0 +1,61 @@ +/** + * Authenticate the wizard — once per invocation. + * + * Idempotent: when `session.credentials` is already set, this is a no-op. So a + * second agent run in the same invocation (e.g. self-driving runs the + * integration program as a phase, then the Self-driving run) reuses the first + * login instead of launching another OAuth — a second OAuth re-prompts and + * fails with a 400 (the first authorization code is already spent). The first + * call stores the full result on the session so any later bootstrap reads it + * back rather than fetching again. + */ + +import type { WizardSession } from '@lib/wizard-session'; +import type { ProgramId } from '@lib/programs/program-registry'; +import { getOrAskForProjectData } from '@utils/setup-utils'; +import { analytics, groupsFromUser } from '@utils/analytics'; +import { getUI } from '@ui'; +import { logToFile } from '@utils/debug'; + +export async function authenticate( + session: WizardSession, + programId: ProgramId, +): Promise { + if (session.credentials) return; + + logToFile('[agent-runner] starting OAuth'); + const { + projectApiKey, + host, + accessToken, + projectId, + cloudRegion, + roleAtOrganization, + user, + project, + } = await getOrAskForProjectData({ + signup: session.signup, + ci: session.ci, + apiKey: session.apiKey, + projectId: session.projectId, + email: session.email, + region: session.region, + baseUrl: session.baseUrl, + programId, + }); + + session.credentials = { accessToken, projectApiKey, host, projectId }; + session.cloudRegion = cloudRegion; + session.apiProject = project; + session.roleAtOrganization = roleAtOrganization; + session.apiUser = user; + + getUI().setCredentials(session.credentials); + getUI().setRoleAtOrganization(roleAtOrganization); + getUI().setApiUser(user); + + // 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)); +} diff --git a/src/lib/agent/runner/shared/bootstrap.ts b/src/lib/agent/runner/shared/bootstrap.ts index b1b2fab3..cd39b0a0 100644 --- a/src/lib/agent/runner/shared/bootstrap.ts +++ b/src/lib/agent/runner/shared/bootstrap.ts @@ -8,9 +8,9 @@ */ import type { WizardSession } from '@lib/wizard-session'; -import { getOrAskForProjectData } from '@utils/setup-utils'; -import { analytics, groupsFromUser } from '@utils/analytics'; +import { analytics } from '@utils/analytics'; import { getUI } from '@ui'; +import { authenticate } from './authenticate'; import { buildRunTags } from '@lib/agent/agent-interface'; import { checkAllSettingsConflicts, @@ -210,39 +210,14 @@ export async function bootstrapProgram( skill_id: config.skillId ?? null, }); - // 4. OAuth - logToFile('[agent-runner] starting OAuth'); - const { - projectApiKey, - host, - accessToken, - projectId, - cloudRegion, - roleAtOrganization, - user, - project, - } = await getOrAskForProjectData({ - signup: session.signup, - ci: session.ci, - apiKey: session.apiKey, - projectId: session.projectId, - email: session.email, - region: session.region, - baseUrl: session.baseUrl, - programId: programConfig.id, - }); - - session.credentials = { accessToken, projectApiKey, host, projectId }; - session.roleAtOrganization = roleAtOrganization; - session.apiUser = user; - getUI().setCredentials(session.credentials); - getUI().setRoleAtOrganization(roleAtOrganization); - getUI().setApiUser(user); - - // Identify the user (email, name) before evaluating flags, so flags can target - // the individual user and not just $app_name. - if (user) analytics.identifyUser(user); - analytics.setGroups(groupsFromUser(user, host)); + // 4. Authenticate — idempotent within a run (see authenticate()). A second + // agent run in the same invocation (self-driving's integration phase) reuses + // 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 // up if the org hasn't approved third-party AI — BEFORE the skill diff --git a/src/lib/programs/__tests__/self-driving-detect.test.ts b/src/lib/programs/__tests__/self-driving-detect.test.ts index 10bd3280..e687dc92 100644 --- a/src/lib/programs/__tests__/self-driving-detect.test.ts +++ b/src/lib/programs/__tests__/self-driving-detect.test.ts @@ -6,6 +6,7 @@ import { selfDrivingConfig, SELF_DRIVING_ABORT_CASES, } from '@lib/programs/self-driving/index'; +import { detectPostHogPresent } from '@lib/programs/self-driving/detect'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import { buildSession } from '@lib/wizard-session'; import type { Mock } from 'vitest'; @@ -97,10 +98,12 @@ describe('selfDrivingConfig', () => { expect(typeof last === 'object' ? last.pause : undefined).toBe(5000); }); - it('gives wizard_ask a 30-min timeout for the browser-handoff steps', () => { + it('gives wizard_ask a 30-min timeout for the browser-handoff steps', async () => { + // `run` is resolved per-session so the prompt can carry the integrate flag. const { run } = selfDrivingConfig; - const timeout = typeof run === 'object' ? run.askTimeoutMs : undefined; - expect(timeout).toBe(30 * 60 * 1000); + const resolved = + typeof run === 'function' ? await run(buildSession({})) : run; + expect(resolved?.askTimeoutMs).toBe(30 * 60 * 1000); }); it('wires the self-driving-setup skill and CLI command', () => { @@ -116,10 +119,51 @@ describe('selfDrivingConfig', () => { expect(stepIds).toEqual([ 'detect', 'intro', + 'integration-check', 'health-check', 'auth', + 'integrate-detect', + 'integrate-run', + 'self-driving-handoff', 'run', 'outro', ]); }); }); + +describe('detectPostHogPresent', () => { + it('returns true when a manifest declares a PostHog package', () => { + const dir = makeTmpDir(); + try { + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ dependencies: { 'posthog-node': '^4.0.0' } }), + ); + expect(detectPostHogPresent(dir)).toBe(true); + } finally { + cleanup(dir); + } + }); + + it('returns false when no manifest mentions PostHog', () => { + const dir = makeTmpDir(); + try { + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ dependencies: { express: '^4.0.0' } }), + ); + expect(detectPostHogPresent(dir)).toBe(false); + } finally { + cleanup(dir); + } + }); + + it('returns false for an empty project', () => { + const dir = makeTmpDir(); + try { + expect(detectPostHogPresent(dir)).toBe(false); + } finally { + cleanup(dir); + } + }); +}); diff --git a/src/lib/programs/__tests__/self-driving-prompt.test.ts b/src/lib/programs/__tests__/self-driving-prompt.test.ts new file mode 100644 index 00000000..c6975b8f --- /dev/null +++ b/src/lib/programs/__tests__/self-driving-prompt.test.ts @@ -0,0 +1,21 @@ +import { buildSelfDrivingPrompt } from '@lib/programs/self-driving/prompt'; +import type { PromptContext } from '@lib/agent/agent-runner'; + +const ctx: PromptContext = { + projectId: 123, + projectApiKey: 'phc_test', + host: 'https://us.posthog.com', +}; + +describe('buildSelfDrivingPrompt', () => { + it('covers only the Self-driving steps — integration is a separate phase', () => { + const prompt = buildSelfDrivingPrompt(ctx); + // No SDK-integration step in the prompt; that runs as the prelude program. + expect(prompt).not.toContain('STEP 0'); + expect(prompt).not.toContain('Integrate the PostHog SDK'); + expect(prompt).not.toContain('load_skill_menu'); + // The Self-driving steps are present. + expect(prompt).toContain('STEP 1 — Check Self-driving access'); + expect(prompt).toContain('Connect GitHub'); + }); +}); diff --git a/src/lib/programs/posthog-integration/index.ts b/src/lib/programs/posthog-integration/index.ts index 11c85559..238e87bc 100644 --- a/src/lib/programs/posthog-integration/index.ts +++ b/src/lib/programs/posthog-integration/index.ts @@ -1,8 +1,8 @@ -import type { ProgramConfig } from '@lib/programs/program-step'; -import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step'; +import { runAgent, type ProgramRun } from '@lib/agent/agent-runner'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import type { WizardSession } from '@lib/wizard-session'; -import { OutroKind } from '@lib/wizard-session'; +import { OutroKind, RunPhase } from '@lib/wizard-session'; import { AgentSignals } from '@lib/agent/agent-interface'; import { DEFAULT_PACKAGE_INSTALLATION, @@ -277,3 +277,20 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server }; export { POSTHOG_INTEGRATION_PROGRAM } from './steps.js'; + +/** + * Self-contained run step that runs the integration agent. Other programs + * import this and splice it into their own step list to compose the + * integration's work as one of their run steps — self-driving sets up PostHog + * this way before its own run. The host program supplies `show`/`onRunPrep`/ + * `targetDir`; this carries the run. + */ +export const integrationRunStep: ProgramStep = { + id: 'run', + label: 'Integration', + screenId: 'run', + run: (session) => runAgent(posthogIntegrationConfig, session), + isComplete: (session) => + session.runPhase === RunPhase.Completed || + session.runPhase === RunPhase.Error, +}; diff --git a/src/lib/programs/program-step.ts b/src/lib/programs/program-step.ts index 55a06132..78968043 100644 --- a/src/lib/programs/program-step.ts +++ b/src/lib/programs/program-step.ts @@ -67,6 +67,30 @@ export interface ProgramStep { */ screenId?: string; + /** + * For a run step (`screenId: 'run'`): runs this step's own agent. A program + * exports a self-contained run step and another imports it into its step list + * — e.g. posthog-integration exports a run step that runs its agent, and + * self-driving imports it before its own run step. Omit to run the host + * program's own agent (`config.run`). + */ + run?: (session: WizardSession) => Promise; + + /** + * For a run step: prepare a derived session before its agent runs — e.g. + * gather framework context for the chosen project. The session it receives is + * the run's own, so writes don't leak into later runs. + */ + onRunPrep?: (session: WizardSession) => Promise; + + /** + * For a run step: the working directory its agent runs in, resolved from the + * session (e.g. self-driving's integration runs in the picked monorepo + * sub-app, not the repo root). The runner scopes a derived session to this + * dir for that run only. Defaults to `session.installDir`. + */ + targetDir?: (session: WizardSession) => string; + /** * Whether this step should be visible in the current program. * If omitted, the step is always visible. diff --git a/src/lib/programs/self-driving/detect-agentic.ts b/src/lib/programs/self-driving/detect-agentic.ts new file mode 100644 index 00000000..1918da99 --- /dev/null +++ b/src/lib/programs/self-driving/detect-agentic.ts @@ -0,0 +1,138 @@ +/** + * Agentic (Haiku) detection for self-driving's integration phase. + * + * Mirrors source-maps: the integration-detect screen runs `detectSelfDriving- + * IntegrationProjects` (the shared Haiku detector with the integration framework + * targets), shows a project map, and the user picks one. This file supplies the + * targets, maps the result back to `Integration`s, and classifies each project + * as instrumentable (a framework the wizard supports that doesn't already have + * PostHog). The screen writes the choice to the session; `prepSelfDriving- + * Integration` then gathers the chosen project's framework context before the + * integration agent runs (the runner scopes the install dir). + */ + +import { + detectProjectsWithAgent, + type DetectTarget, + type AgenticDetectionReport, + type DetectEvent, +} from '@lib/detection/agentic'; +import { gatherFrameworkContext } from '@lib/detection/index'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import { Integration } from '@lib/constants'; +import type { WizardSession } from '@lib/wizard-session'; + +export type { DetectEvent }; + +/** Integration framework targets for the agentic detector (id → display name). */ +const INTEGRATION_TARGETS: DetectTarget[] = Object.entries( + FRAMEWORK_REGISTRY, +).map(([id, config]) => ({ id, name: config.metadata.name })); + +const INTEGRATION_IDS = new Set(Object.values(Integration)); + +/** One project, classified for a PostHog SDK integration. */ +export type IntegrationProject = { + /** Path relative to the repo root ("." for the root). */ + path: string; + /** Human-readable framework the agent detected (e.g. "Next.js"). */ + framework: string; + /** A supported framework when it matches one, else null. */ + integration: Integration | null; + /** Whether a PostHog SDK is already installed in this project. */ + hasPostHog: boolean; + /** integration != null && !hasPostHog — PostHog can be set up here. */ + instrumentable: boolean; + /** Why the project can't be set up (only when !instrumentable). */ + reason?: string; +}; + +export type IntegrationDetectionReport = { + repoType: 'monorepo' | 'single'; + projects: IntegrationProject[]; +}; + +function classify( + integration: Integration | null, + hasPostHog: boolean, +): { instrumentable: boolean; reason?: string } { + if (integration == null) { + return { + instrumentable: false, + reason: 'Not a framework the wizard can set up yet', + }; + } + if (hasPostHog) { + return { instrumentable: false, reason: 'Already has the PostHog SDK' }; + } + return { instrumentable: true }; +} + +/** Map a generic detection report into classified integration projects. */ +function toIntegrationReport( + report: AgenticDetectionReport, +): IntegrationDetectionReport { + return { + repoType: report.repoType, + projects: report.projects.map((p) => { + const integration = + p.targetId && INTEGRATION_IDS.has(p.targetId) + ? (p.targetId as Integration) + : null; + return { + path: p.path, + framework: p.framework, + integration, + hasPostHog: p.hasPostHog, + ...classify(integration, p.hasPostHog), + }; + }), + }; +} + +/** Run the Haiku detector over the repo and classify projects for integration. */ +export async function detectSelfDrivingIntegrationProjects( + session: WizardSession, + onEvent?: DetectEvent, +): Promise { + const report = await detectProjectsWithAgent(session, { + targets: INTEGRATION_TARGETS, + purpose: 'set up a PostHog SDK integration', + onEvent, + }); + return toIntegrationReport(report); +} + +/** + * Prepare the integration phase after the user picked a project on the + * integration-detect screen (which set `session.integration`, `frameworkConfig`, + * and the chosen path). Scopes `installDir` to the chosen project — so a + * monorepo integrates into the picked sub-app — and gathers that project's + * framework context, mirroring posthog-integration's `ciPreRun`. Used as the + * integrate-run step's `onRunPrep`. + */ +export async function prepSelfDrivingIntegration( + session: WizardSession, +): Promise { + // `session` is the phase's derived session — its installDir is already the + // picked project (the integrate-run step's `targetDir`), so just gather that + // project's framework context. + const frameworkConfig = session.frameworkConfig; + if (!frameworkConfig) return; + + const context = await gatherFrameworkContext(frameworkConfig, { + installDir: session.installDir, + debug: session.debug, + default: false, + signup: session.signup, + localMcp: session.localMcp, + ci: session.ci, + benchmark: session.benchmark, + yaraReport: session.yaraReport, + }); + for (const [key, value] of Object.entries(context)) { + if (!(key in session.frameworkContext)) { + session.frameworkContext[key] = value; + } + } +} diff --git a/src/lib/programs/self-driving/detect.ts b/src/lib/programs/self-driving/detect.ts index 6926b2be..4e956890 100644 --- a/src/lib/programs/self-driving/detect.ts +++ b/src/lib/programs/self-driving/detect.ts @@ -19,10 +19,52 @@ * the wizard-side "self-driving" rename. */ -import { existsSync, statSync } from 'fs'; +import { existsSync, statSync, readFileSync } from 'fs'; +import { join } from 'path'; import type { WizardSession } from '@lib/wizard-session'; import type { AbortCase } from '@lib/agent/agent-runner'; +/** frameworkContext key holding the deterministic PostHog-presence result. */ +export const POSTHOG_PRESENT_KEY = 'postHogPresent'; + +/** + * frameworkContext key holding the repo-relative path of the project the user + * picked on the integration-detect screen ("." for the repo root). The + * integrate-run phase scopes its install dir to this so a monorepo integrates + * into the chosen sub-app, not the root. + */ +export const SELF_DRIVING_INTEGRATE_PATH_KEY = 'selfDrivingIntegratePath'; + +/** + * Deterministic, offline check: does the project already have a PostHog SDK? + * Scans the common dependency manifests at the install dir for a `posthog` + * package — the same signal the agentic detector reports as `hasPostHog`, but + * instant and credential-free. Drives whether self-driving asks to integrate + * first ("not found") or proceeds straight to setup ("found"). + */ +export function detectPostHogPresent(installDir: string): boolean { + const manifests = [ + 'package.json', + 'requirements.txt', + 'pyproject.toml', + 'Pipfile', + 'Gemfile', + 'go.mod', + 'composer.json', + 'pubspec.yaml', + ]; + for (const name of manifests) { + const path = join(installDir, name); + if (!existsSync(path)) continue; + try { + if (/posthog/i.test(readFileSync(path, 'utf8'))) return true; + } catch { + /* unreadable — ignore */ + } + } + return false; +} + /** * Structured detection errors. The intro screen renders each kind into * JSX — keeps error data separate from presentation. @@ -109,4 +151,9 @@ export function detectSelfDrivingPrerequisites( fail({ kind: 'bad-directory', path: installDir, reason: 'unreadable' }); return; } + + // Deterministic PostHog-presence check — drives the integration-check + // screen: found → skip straight to self-driving; not found → ask to set up + // PostHog first. + setFrameworkContext(POSTHOG_PRESENT_KEY, detectPostHogPresent(installDir)); } diff --git a/src/lib/programs/self-driving/prompt.ts b/src/lib/programs/self-driving/prompt.ts index afb25533..fd6e1b29 100644 --- a/src/lib/programs/self-driving/prompt.ts +++ b/src/lib/programs/self-driving/prompt.ts @@ -8,6 +8,10 @@ import { getUiHostFromHost } from '@utils/urls'; * every step (which MCP tools to call, which sources/scouts apply, how * to verify); this prompt carries the order, the wizard-specific * mechanics (wizard_ask, abort signals), and the project URLs. + * + * Integration (when the project has no PostHog yet) runs as a separate phase + * before this — the real integration program, with its own screens and task + * list — so this prompt only covers the Self-driving steps. */ export function buildSelfDrivingPrompt(ctx: PromptContext): string { const uiHost = getUiHostFromHost(ctx.host).replace(/\/$/, ''); diff --git a/src/lib/programs/self-driving/steps.ts b/src/lib/programs/self-driving/steps.ts index 1424ef33..19f72d88 100644 --- a/src/lib/programs/self-driving/steps.ts +++ b/src/lib/programs/self-driving/steps.ts @@ -1,24 +1,49 @@ /** * Self-driving program step list. * - * detect → intro → health-check → auth → run → outro. No keep-skills - * step: the setup skill is transient orchestration knowledge the user - * won't reuse, so postRun removes it instead of prompting. + * detect → intro → integration-check → health-check → auth → integrate-detect → + * integrate-run → self-driving-handoff → run → outro. A deterministic check in + * `detect` decides whether PostHog is already in the project: found → the + * integration screens are skipped and the integrate-run phase never shows; not + * found → integration-check reports it and the only action sets up PostHog. + * After auth, `integrate-detect` runs the Haiku detector and has the user pick + * which project to set PostHog up in (a monorepo can have several); + * `integrate-run` then runs the real integration program's agent (its own task + * list) in that project. `self-driving-handoff` then bridges to Self-driving + * ("PostHog is installed — now set up Self-driving") before the Self-driving + * run. No keep-skills step: the setup skill is transient, so postRun removes it. */ +import { resolve } from 'path'; import type { ProgramStep } from '@lib/programs/program-step'; -import { RunPhase } from '@lib/wizard-session'; +import { RunPhase, type WizardSession } from '@lib/wizard-session'; import { HEALTH_CHECK_STEP } from '@lib/programs/shared/health-check-step'; -import { detectSelfDrivingPrerequisites } from './detect.js'; +import { integrationRunStep } from '@lib/programs/posthog-integration/index'; +import { + detectSelfDrivingPrerequisites, + POSTHOG_PRESENT_KEY, + SELF_DRIVING_INTEGRATE_PATH_KEY, +} from './detect.js'; +import { prepSelfDrivingIntegration } from './detect-agentic.js'; + +/** True once detection found PostHog already present in the project. */ +const postHogPresent = (session: WizardSession): boolean => + session.frameworkContext[POSTHOG_PRESENT_KEY] === true; + +/** Absolute dir to integrate into: the picked sub-app, else the repo root. */ +const integrationDir = (session: WizardSession): string => { + const rel = session.frameworkContext[SELF_DRIVING_INTEGRATE_PATH_KEY]; + return typeof rel === 'string' && rel !== '.' + ? resolve(session.installDir, rel) + : session.installDir; +}; export const SELF_DRIVING_PROGRAM: ProgramStep[] = [ { id: 'detect', label: 'Detecting prerequisites', - // Headless step: no screen, no gate. onReady fires after bin.ts - // assigns the session — verifies the PostHog setup report exists - // and writes a detectError to frameworkContext for the intro - // screen to render when it doesn't. + // Headless: validates the install dir and runs the deterministic + // PostHog-presence check (writes frameworkContext.postHogPresent). onReady: (ctx) => detectSelfDrivingPrerequisites(ctx.session, ctx.setFrameworkContext), }, @@ -28,6 +53,20 @@ export const SELF_DRIVING_PROGRAM: ProgramStep[] = [ screenId: 'self-driving-intro', gate: (session) => session.setupConfirmed, }, + { + // Shown only when PostHog wasn't detected and the decision is still open: + // "Set up PostHog first?". On "yes" the integrate-run phase runs the + // integration. When PostHog is already present (or `--integrate` pre-set + // it), this is skipped. The gate lets run-wizard settle the decision — + // resolved immediately when no question is needed. + id: 'integration-check', + label: 'Integration', + screenId: 'self-driving-integration-check', + show: (session) => !postHogPresent(session) && session.integrate === null, + isComplete: (session) => + postHogPresent(session) || session.integrate !== null, + gate: (session) => postHogPresent(session) || session.integrate !== null, + }, HEALTH_CHECK_STEP, { id: 'auth', @@ -35,6 +74,42 @@ export const SELF_DRIVING_PROGRAM: ProgramStep[] = [ screenId: 'auth', isComplete: (session) => session.credentials !== null, }, + { + // After auth, before the integration runs: the detector scans the repo and + // the user picks which project to set PostHog up in — a single project or + // the repo root is still a one-item confirm. The pick writes the framework + + // path to the session. Shown only while integrating and undecided; complete + // once a project is picked (the orchestrator waits on this live). + id: 'integrate-detect', + label: 'Detecting', + screenId: 'self-driving-integration-detect', + show: (session) => + session.integrate === true && session.integration == null, + isComplete: (session) => session.integration != null, + }, + { + // The integration's own run step, imported and composed here: it runs the + // integration agent (its prompt, tools, task list) in the picked project's + // dir. Shown only when integrating; prep gathers that project's framework + // context. Completion is tracked via `completedRuns`, separate from the + // Self-driving run's `runPhase`. + ...integrationRunStep, + id: 'integrate-run', + onRunPrep: prepSelfDrivingIntegration, + targetDir: integrationDir, + show: (session) => session.integrate === true, + isComplete: (session) => session.completedRuns.includes('integrate-run'), + }, + { + // Handoff after the integration run: "PostHog is installed — now set up + // Self-driving". Only in the integrate path; the already-has-PostHog path + // skips it. Complete once acknowledged (the orchestrator waits on this). + id: 'self-driving-handoff', + label: 'Ready', + screenId: 'self-driving-handoff', + show: (session) => session.integrate === true, + isComplete: (session) => session.selfDrivingHandoffConfirmed, + }, { id: 'run', label: 'Self-driving', diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index 5e2aef59..7f8fc341 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -1,13 +1,59 @@ import { VERSION } from '@lib/version'; import { logToFile, getLogFilePath } from '@utils/debug'; +import { runAgent } from '@lib/agent/agent-runner'; +import { authenticate } from '@lib/agent/runner/shared/authenticate'; import type { ProgramConfig } from '@lib/programs/program-step'; import type { startTUI as StartTUIFn } from '@ui/tui/start-tui'; +import type { WizardStore } from '@ui/tui/store'; +import type { WizardSession } from '@lib/wizard-session'; import type { TaskStreamPush as TaskStreamPushClass } from '@lib/task-stream/task-stream-push'; import { resolveNoTelemetry } from './resolve-no-telemetry'; import { runCleanups } from '@utils/wizard-abort'; const WIZARD_VERSION = VERSION; +type Step = ProgramConfig['steps'][number]; + +/** The session a run step's agent runs in: scoped to the step's target dir + * (e.g. a monorepo sub-app) with its own framework context, after any prep. + * A step without `targetDir` runs in the live session, unchanged. */ +async function prepareRunSession( + step: Step, + live: WizardSession, +): Promise { + const session = step.targetDir + ? { + ...live, + installDir: step.targetDir(live), + frameworkContext: { ...live.frameworkContext }, + } + : live; + if (step.onRunPrep) await step.onRunPrep(session); + return session; +} + +/** Advance one step of a composed run to completion: the auth screen + * authenticates (every later run reuses it); a step carrying its own `run` + * thunk runs that agent in its dir and is recorded in `completedRuns`; the + * host program's own run screen runs `config.run`; any other screen waits for + * the user to satisfy `isComplete`. */ +async function advanceStep( + step: Step, + store: WizardStore, + config: ProgramConfig, +): Promise { + if (step.screenId === 'auth') { + await authenticate(store.session, config.id); + } else if (step.run) { + await step.run(await prepareRunSession(step, store.session)); + store.completeRunStep(step.id); + } else if (step.screenId === 'run') { + await runAgent(config, await prepareRunSession(step, store.session)); + } else if (step.isComplete) { + await store.waitUntil(step.isComplete); + } +} + /** * Run a full wizard program in the TUI. Handles the full lifecycle: start TUI, * build session, run detection, wait for intro gate, execute the @@ -50,6 +96,7 @@ export function runWizard( benchmark: options.benchmark as boolean | undefined, yaraReport: options.yaraReport as boolean | undefined, noTelemetry: resolveNoTelemetry(options), + integrate: options.integrate as boolean | undefined, }); session.programLabel = config.id; if (options.skillId) { @@ -107,12 +154,25 @@ export function runWizard( process.on('SIGTERM', onSignal); await activeTui.store.runReadyHooks(); + // Settle the pre-run screens. `integration-check` is a no-op gate for + // programs without it. await activeTui.store.getGate('intro'); + await activeTui.store.getGate('integration-check'); await activeTui.store.getGate('health-check'); const skipAgent = config.run == null; + const shown = (s: ProgramConfig['steps'][number]) => + !s.show || s.show(activeTui.store.session); - if (skipAgent) { + if (config.steps.some((s) => s.run)) { + // A composed program: its step list splices in run steps that carry + // their own agent (self-driving runs the integration before its own + // run). Walk the list once, advancing each step to completion. + for (const step of config.steps) { + if (step.screenId === 'outro') break; // run-completion wait owns it + if (shown(step)) await advanceStep(step, activeTui.store, config); + } + } else if (skipAgent) { const { getOrAskForProjectData } = await import('@utils/setup-utils'); const { projectApiKey, host, accessToken, projectId } = await getOrAskForProjectData({ @@ -130,7 +190,6 @@ export function runWizard( projectId, }); } else { - const { runAgent } = await import('@lib/agent/agent-runner'); await runAgent(config, activeTui.store.session); } diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 4aef4f7d..ae9c277c 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -14,7 +14,7 @@ import type { Integration } from './constants'; import type { FrameworkConfig } from './framework-config'; import type { WizardReadinessResult } from './health-checks/readiness'; import type { SettingsConflict } from './agent/claude-settings'; -import type { ApiUser } from './api'; +import type { ApiUser, ApiProject } from './api'; export interface Credentials { accessToken: string; @@ -233,6 +233,14 @@ 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. + */ + cloudRegion: CloudRegion | null; + apiProject: ApiProject | null; + // Lifecycle runPhase: RunPhase; loginUrl: string | null; @@ -261,6 +269,31 @@ export interface WizardSession { skillsComplete: boolean; outroDismissed: boolean; + /** + * Self-driving only: whether to integrate PostHog as part of this run. + * `null` until decided — the integration-check screen asks "do you already + * have PostHog?" and sets it (No → true, Yes → false). The `--integrate` + * flag pre-sets it to `true`, skipping the question. When `true`, the + * self-driving prompt has the agent set up the SDK before the Self-driving + * steps. Unused by other programs. + */ + integrate: boolean | null; + + /** + * Ids of composed run steps that have completed — e.g. self-driving's + * `integrate-run`. Lets a run step's `isComplete` hold after it ran, + * independent of the shared `runPhase`. + */ + completedRuns: string[]; + + /** + * Self-driving only: whether the user confirmed the handoff screen shown + * after the integration run ("PostHog is installed — now set up Self-driving"). + * Gates the Self-driving run so it doesn't start until acknowledged. Only + * reached in the integrate path; the already-has-PostHog path skips it. + */ + selfDrivingHandoffConfirmed: boolean; + // Runtime readinessResult: WizardReadinessResult | null; outageDismissed: boolean; @@ -314,6 +347,7 @@ export function buildSession(args: { yaraReport?: boolean; projectId?: string; noTelemetry?: boolean; + integrate?: boolean; }): WizardSession { return { debug: args.debug ?? false, @@ -350,11 +384,18 @@ export function buildSession(args: { slackConnected: null, skillsComplete: false, outroDismissed: false, + // `--integrate` forces integration (skip the question); otherwise the + // integration-check screen resolves it from null. + integrate: args.integrate === true ? true : null, + completedRuns: [], + selfDrivingHandoffConfirmed: false, loginUrl: null, authorizeUrl: null, credentials: null, roleAtOrganization: null, apiUser: null, + cloudRegion: null, + apiProject: null, readinessResult: null, outageDismissed: false, settingsOverrideKeys: null, diff --git a/src/ui/tui/__tests__/router.test.ts b/src/ui/tui/__tests__/router.test.ts index 9cefe69d..a5c38bf3 100644 --- a/src/ui/tui/__tests__/router.test.ts +++ b/src/ui/tui/__tests__/router.test.ts @@ -1,6 +1,8 @@ import { buildSession, McpOutcome, RunPhase } from '@lib/wizard-session'; import { WizardReadiness } from '@lib/health-checks/readiness'; import { WizardRouter, ScreenId, Overlay, Program } from '@ui/tui/router'; +import { Integration } from '@lib/constants'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; function baseWizardSession() { return buildSession({}); @@ -184,4 +186,68 @@ describe('WizardRouter', () => { expect(router.resolve(session)).toBe(ScreenId.Exit); }); }); + + describe('self-driving integration-check', () => { + function confirmed() { + const session = baseWizardSession(); + session.setupConfirmed = true; // self-driving intro confirmed + return session; + } + + it('asks "set up PostHog?" when none detected and undecided', () => { + const router = new WizardRouter(Program.SelfDriving); + const session = confirmed(); // integrate null, postHogPresent unset + expect(router.resolve(session)).toBe( + ScreenId.SelfDrivingIntegrationCheck, + ); + }); + + it('skips the question when PostHog is already detected', () => { + const router = new WizardRouter(Program.SelfDriving); + const session = confirmed(); + session.frameworkContext.postHogPresent = true; + expect(router.resolve(session)).toBe(ScreenId.HealthCheck); + }); + + it('skips the question when --integrate pre-decided it', () => { + const router = new WizardRouter(Program.SelfDriving); + const session = confirmed(); + session.integrate = true; + expect(router.resolve(session)).toBe(ScreenId.HealthCheck); + }); + + function readyToIntegrate() { + const session = confirmed(); + session.integrate = true; + session.readinessResult = { + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }; + session.credentials = { + accessToken: 'tok', + projectApiKey: 'pk', + host: 'https://app.posthog.com', + projectId: 1, + }; + return session; + } + + it('shows the detect+pick screen after auth, before a project is picked', () => { + const router = new WizardRouter(Program.SelfDriving); + const session = readyToIntegrate(); // integration still null + expect(router.resolve(session)).toBe( + ScreenId.SelfDrivingIntegrationDetect, + ); + }); + + it('advances to the integration run once a project is picked', () => { + const router = new WizardRouter(Program.SelfDriving); + const session = readyToIntegrate(); + session.integration = Integration.javascriptNode; // picked + session.frameworkConfig = FRAMEWORK_REGISTRY[Integration.javascriptNode]; + // integrate-run shares the 'run' screen; the phase hasn't completed yet. + expect(router.resolve(session)).toBe(ScreenId.Run); + }); + }); }); diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index e177caac..740f123c 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -1323,4 +1323,28 @@ describe('WizardStore', () => { expect(resolved).toBe(true); }); }); + + describe('setIntegrate (self-driving integration check)', () => { + it('records "no" as integrate=true', () => { + const store = createStore(Program.SelfDriving); + store.session = buildSession({}); + store.setIntegrate(true); + expect(store.session.integrate).toBe(true); + }); + + it('records "yes, already integrated" as integrate=false', () => { + const store = createStore(Program.SelfDriving); + store.session = buildSession({}); + store.setIntegrate(false); + expect(store.session.integrate).toBe(false); + }); + + it('defaults to null (undecided) before the question', () => { + expect(buildSession({}).integrate).toBeNull(); + }); + + it('--integrate pre-resolves the decision to true', () => { + expect(buildSession({ integrate: true }).integrate).toBe(true); + }); + }); }); diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 2a5f49e8..2521e979 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -28,6 +28,9 @@ import { SourceMapsDetectScreen } from './screens/SourceMapsDetectScreen.js'; import { SourceMapsOutroScreen } from './screens/SourceMapsOutroScreen.js'; import { AgentSkillIntroScreen } from './screens/AgentSkillIntroScreen.js'; import { SelfDrivingIntroScreen } from './screens/SelfDrivingIntroScreen.js'; +import { SelfDrivingIntegrationCheckScreen } from './screens/SelfDrivingIntegrationCheckScreen.js'; +import { SelfDrivingIntegrationDetectScreen } from './screens/SelfDrivingIntegrationDetectScreen.js'; +import { SelfDrivingHandoffScreen } from './screens/SelfDrivingHandoffScreen.js'; import { AuditIntroScreen } from './screens/audit/AuditIntroScreen.js'; import { AuditRunScreen } from './screens/audit/AuditRunScreen.js'; import { AuditOutroScreen } from './screens/audit/AuditOutroScreen.js'; @@ -85,6 +88,13 @@ export function createScreens( [ScreenId.MigrationIntro]: , [ScreenId.AgentSkillIntro]: , [ScreenId.SelfDrivingIntro]: , + [ScreenId.SelfDrivingIntegrationCheck]: ( + + ), + [ScreenId.SelfDrivingIntegrationDetect]: ( + + ), + [ScreenId.SelfDrivingHandoff]: , [ScreenId.AuditIntro]: , [ScreenId.AuditRun]: , [ScreenId.AuditOutro]: , diff --git a/src/ui/tui/screen-sequences.ts b/src/ui/tui/screen-sequences.ts index 60e69c56..39052fd9 100644 --- a/src/ui/tui/screen-sequences.ts +++ b/src/ui/tui/screen-sequences.ts @@ -25,6 +25,9 @@ export enum ScreenId { MigrationIntro = 'migration-intro', AgentSkillIntro = 'agent-skill-intro', SelfDrivingIntro = 'self-driving-intro', + SelfDrivingIntegrationCheck = 'self-driving-integration-check', + SelfDrivingIntegrationDetect = 'self-driving-integration-detect', + SelfDrivingHandoff = 'self-driving-handoff', AuditIntro = 'audit-intro', AuditRun = 'audit-run', AuditOutro = 'audit-outro', diff --git a/src/ui/tui/screens/SelfDrivingHandoffScreen.tsx b/src/ui/tui/screens/SelfDrivingHandoffScreen.tsx new file mode 100644 index 00000000..85c4b1f6 --- /dev/null +++ b/src/ui/tui/screens/SelfDrivingHandoffScreen.tsx @@ -0,0 +1,74 @@ +/** + * SelfDrivingHandoffScreen — the bridge between the integration run and the + * Self-driving run. Shown only in the integrate path (PostHog wasn't present, so + * the wizard set it up first); the already-has-PostHog path skips straight to + * Self-driving with no note. Confirms the SDK is in, then hands off to the + * Self-driving run. + */ + +import { Box, Text } from 'ink'; +import { useSyncExternalStore } from 'react'; + +import type { WizardStore } from '@ui/tui/store'; +import { PickerMenu } from '@ui/tui/primitives/index'; +import { Colors } from '@ui/tui/styles'; +import { SETUP_REPORT_FILE } from '@lib/programs/posthog-integration/index'; +import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '@lib/programs/self-driving/detect'; + +interface SelfDrivingHandoffScreenProps { + store: WizardStore; +} + +export const SelfDrivingHandoffScreen = ({ + store, +}: SelfDrivingHandoffScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + // The integration ran in the picked project (a monorepo sub-app, or the + // root), so the report sits under that path. + const rel = store.session.frameworkContext[SELF_DRIVING_INTEGRATE_PATH_KEY]; + const dir = typeof rel === 'string' && rel !== '.' ? `${rel}/` : ''; + const reportPath = `./${dir}${SETUP_REPORT_FILE}`; + + return ( + + + ✔ PostHog is installed + + + + Now let's make your product self-driving. + + + PostHog is now integrated in your project. Now, let's connect + to GitHub, turns on signal sources, and runs scouts that watch your + product data and surface what needs attention. + + + + + This should take about 20 minutes and will occasionally need your + input. + + + + + + store.confirmSelfDrivingHandoff()} + /> + + + + + You can find your PostHog integration report at{' '} + {reportPath} + + + + ); +}; diff --git a/src/ui/tui/screens/SelfDrivingIntegrationCheckScreen.tsx b/src/ui/tui/screens/SelfDrivingIntegrationCheckScreen.tsx new file mode 100644 index 00000000..d8b8da4b --- /dev/null +++ b/src/ui/tui/screens/SelfDrivingIntegrationCheckScreen.tsx @@ -0,0 +1,50 @@ +/** + * SelfDrivingIntegrationCheckScreen — shown only when detection found no + * PostHog in the project. It's a notice, not a question: Self-driving requires + * a PostHog SDK, so the single action sets `integrate = true` and the run + * integrates first. Skipped entirely when PostHog is already present (or under + * `--integrate`). + */ + +import { Box, Text } from 'ink'; +import { useSyncExternalStore } from 'react'; + +import type { WizardStore } from '@ui/tui/store'; +import { PickerMenu } from '@ui/tui/primitives/index'; +import { Colors } from '@ui/tui/styles'; + +interface SelfDrivingIntegrationCheckScreenProps { + store: WizardStore; +} + +export const SelfDrivingIntegrationCheckScreen = ({ + store, +}: SelfDrivingIntegrationCheckScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + return ( + + + No PostHog integration found + + + + + We didn't find an existing PostHog integration in your project. + Before you can self-drive, you'll need to integrate PostHog into + your project to capture events and generate signals. + + + + + store.setIntegrate(true)} + /> + + + ); +}; diff --git a/src/ui/tui/screens/SelfDrivingIntegrationDetectScreen.tsx b/src/ui/tui/screens/SelfDrivingIntegrationDetectScreen.tsx new file mode 100644 index 00000000..89e32e98 --- /dev/null +++ b/src/ui/tui/screens/SelfDrivingIntegrationDetectScreen.tsx @@ -0,0 +1,254 @@ +/** + * SelfDrivingIntegrationDetectScreen — runs the Haiku detector over the repo, + * streams progress, then renders a picker of the projects PostHog can be set up + * in (a supported framework, no SDK yet). The user picks one — a single project + * or the repo root is still shown as a one-item menu to confirm — and the choice + * (framework + path) is written to the session; the integrate-run phase sets + * PostHog up there. On a detection error, falls back to a manual framework + * picker so the run can still proceed. + * + * Runs after auth — the detector needs credentials. + */ + +import { Box, Text } from 'ink'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import type { WizardStore } from '@ui/tui/store'; +import { LoadingBox, PickerMenu } from '@ui/tui/primitives/index'; +import { Colors, Icons } from '@ui/tui/styles'; +import { Integration } from '@lib/constants'; +import { FRAMEWORK_REGISTRY } from '@lib/registry'; +import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '@lib/programs/self-driving/detect'; +import { + detectSelfDrivingIntegrationProjects, + type IntegrationProject, + type IntegrationDetectionReport, +} from '@lib/programs/self-driving/detect-agentic'; + +interface SelfDrivingIntegrationDetectScreenProps { + store: WizardStore; +} + +type DetectState = + | { kind: 'loading' } + | { kind: 'ready'; report: IntegrationDetectionReport } + | { kind: 'error'; message: string }; + +const CANCEL = '__cancel'; +const MAX_ACTIVITY_LINES = 8; + +function projectLabel(p: IntegrationProject): string { + const where = p.path === '.' ? 'repo root' : p.path; + return `${p.framework} ${Icons.bullet} ${where}`; +} + +export const SelfDrivingIntegrationDetectScreen = ({ + store, +}: SelfDrivingIntegrationDetectScreenProps) => { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + + const { credentials } = store.session; + const accessToken = credentials?.accessToken; + + const [state, setState] = useState({ kind: 'loading' }); + const [activity, setActivity] = useState([]); + const started = useRef(false); + + // Commit a chosen project: framework + path → session. The run phase reads + // both (path scopes the install dir to the sub-app). + const choose = (p: IntegrationProject) => { + if (!p.integration) return; + store.setFrameworkContext(SELF_DRIVING_INTEGRATE_PATH_KEY, p.path); + store.setFrameworkConfig(p.integration, FRAMEWORK_REGISTRY[p.integration]); + }; + + // Run the detector once, after auth. + useEffect(() => { + if (!accessToken || started.current) return; + started.current = true; + let cancelled = false; + void (async () => { + try { + const report = await detectSelfDrivingIntegrationProjects( + store.session, + (line) => { + if (!cancelled) { + setActivity((prev) => [...prev, line].slice(-MAX_ACTIVITY_LINES)); + } + }, + ); + if (!cancelled) setState({ kind: 'ready', report }); + } catch (err) { + if (!cancelled) { + setState({ + kind: 'error', + message: err instanceof Error ? err.message : String(err), + }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [accessToken, store]); + + if (!credentials) { + return ; + } + + if (state.kind === 'error') { + return ( + + + + {Icons.squareFilled} Detection failed + + {state.message} + + Pick your framework and we'll set PostHog up here. + + + + centered + columns={2} + message="Select your framework" + options={Object.values(Integration).map((v) => ({ + label: v, + value: v, + }))} + onSelect={(value) => { + const integration = Array.isArray(value) ? value[0] : value; + store.setFrameworkContext(SELF_DRIVING_INTEGRATE_PATH_KEY, '.'); + store.setFrameworkConfig( + integration, + FRAMEWORK_REGISTRY[integration], + ); + }} + /> + + ); + } + + if (state.kind === 'loading') { + return ( + + + Detecting your project... + + + + + + {activity.length === 0 ? ( + {' '}Starting up the detection agent… + ) : ( + activity.map((line, i) => ( + + {' '} + {Icons.triangleSmallRight} {line} + + )) + )} + + + ); + } + + const { report } = state; + const instrumentable = report.projects.filter((p) => p.instrumentable); + const blocked = report.projects.filter((p) => !p.instrumentable); + + if (instrumentable.length === 0) { + return ( + + + + {Icons.squareFilled} Nothing to set up here + + + None of the {report.projects.length} projects found can have PostHog + set up. + + + + + process.exit(0)} + /> + + + ); + } + + const single = instrumentable.length === 1; + const options = [ + ...instrumentable.map((p) => ({ label: projectLabel(p), value: p.path })), + { label: 'Cancel', value: CANCEL }, + ]; + + return ( + + + + {Icons.check} Found{' '} + {report.repoType === 'monorepo' ? 'a monorepo' : 'your project'} + + + + { + const path = Array.isArray(value) ? value[0] : value; + if (path === CANCEL) { + process.exit(0); + return; + } + const chosen = instrumentable.find((p) => p.path === path); + if (chosen) choose(chosen); + }} + /> + + + + + + ); +}; + +/** + * Collapses the projects we didn't offer into short count lines — already has + * PostHog, or an unsupported stack. A monorepo can have many, so we summarise. + */ +const BlockedSummary = ({ blocked }: { blocked: IntegrationProject[] }) => { + const alreadyIntegrated = blocked.filter((p) => p.hasPostHog).length; + const unsupported = blocked.length - alreadyIntegrated; + if (blocked.length === 0) return null; + return ( + + {alreadyIntegrated > 0 && ( + + (… {alreadyIntegrated} project{alreadyIntegrated === 1 ? '' : 's'}{' '} + already {alreadyIntegrated === 1 ? 'has' : 'have'} PostHog) + + )} + {unsupported > 0 && ( + + (… {unsupported} project{unsupported === 1 ? '' : 's'} not supported + yet) + + )} + + ); +}; diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index e83efcf5..822ef1fc 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -278,6 +278,25 @@ export class WizardStore { return this._gates.get(stepId)?.promise ?? Promise.resolve(); } + /** + * Resolve once `predicate(session)` is true. Unlike a gate, this is created + * at the await point and evaluated live against the current session, so it + * never latches on a startup value — the orchestrator uses it to wait for a + * decision (a project picked, a handoff acknowledged) without the "true while + * undecided" trap that latched gate predicates have. + */ + waitUntil(predicate: (session: WizardSession) => boolean): Promise { + if (predicate(this.session)) return Promise.resolve(); + return new Promise((resolve) => { + const unsub = this.subscribe(() => { + if (predicate(this.session)) { + unsub(); + resolve(); + } + }); + }); + } + /** * Re-evaluate every gate predicate against the current session and * resolve any whose predicate now returns true. Called after every @@ -672,6 +691,44 @@ export class WizardStore { this.emitChange(); } + /** + * Self-driving integration-check answer. `true` → integrate the SDK as part + * of this run; `false` → PostHog is already set up, go straight to + * Self-driving. Resolves `session.integrate` from null. + */ + setIntegrate(integrate: boolean): void { + this.$session.setKey('integrate', integrate); + analytics.wizardCapture('self-driving integration check', { + self_driving_integrate: integrate, + ...sessionProperties(this.session), + }); + this.emitChange(); + } + + /** + * Self-driving handoff confirmed — the user acknowledged the post-integration + * screen, so the Self-driving run can begin. Gate resolves via _checkGates(). + */ + confirmSelfDrivingHandoff(): void { + this.$session.setKey('selfDrivingHandoffConfirmed', true); + this.emitChange(); + } + + /** + * Mark a composed run step complete (e.g. self-driving's `integrate-run`). + * Records the step id so its `isComplete` predicate holds, clears the task + * list, and resets run phase to Idle so the next run step starts fresh. + */ + completeRunStep(stepId: string): void { + const done = this.session.completedRuns; + if (!done.includes(stepId)) { + this.$session.setKey('completedRuns', [...done, stepId]); + } + this.$tasks.set([]); + this.$session.setKey('runPhase', RunPhase.Idle); + this.emitChange(); + } + setOutroDismissed(): void { this.$session.setKey('outroDismissed', true); this.emitChange(); diff --git a/vitest.config.ts b/vitest.config.ts index a91ac723..26d06cec 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -71,6 +71,8 @@ export default defineConfig({ '**/node_modules/**', '**/dist/**', '**/e2e-tests/**', + // The e2e harness and its tests live in a separate stacked PR. + '**/e2e-harness/**', '**/*.no-jest.*', '**/*.d.ts', ], From 5e0baffbd63d22ce291df452725d66e71658d341 Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 30 Jun 2026 15:06:02 +0200 Subject: [PATCH 2/3] fix: Regex. --- .../__tests__/self-driving-detect.test.ts | 34 +++++ src/lib/programs/self-driving/ARCHITECTURE.md | 142 ++++++++++++++++++ src/lib/programs/self-driving/detect.ts | 6 +- 3 files changed, 181 insertions(+), 1 deletion(-) diff --git a/src/lib/programs/__tests__/self-driving-detect.test.ts b/src/lib/programs/__tests__/self-driving-detect.test.ts index e687dc92..a9c881c5 100644 --- a/src/lib/programs/__tests__/self-driving-detect.test.ts +++ b/src/lib/programs/__tests__/self-driving-detect.test.ts @@ -158,6 +158,40 @@ describe('detectPostHogPresent', () => { } }); + it('does not match "posthog" glued inside a larger package name', () => { + // Only fires at a dependency boundary, so `posthog` glued inside another + // package name isn't a false positive. (A bare word in prose still matches.) + const dir = makeTmpDir(); + try { + fs.writeFileSync( + path.join(dir, 'requirements.txt'), + 'myposthogtool==1.0.0\n', + ); + expect(detectPostHogPresent(dir)).toBe(false); + } finally { + cleanup(dir); + } + }); + + it('detects PostHog declared as a dependency across manifest formats', () => { + const cases: Array<[string, string]> = [ + ['package.json', '{"dependencies":{"posthog-node":"^4.0.0"}}'], + ['requirements.txt', 'flask==3.0\nposthog==3.7.0\n'], + ['Gemfile', "source 'https://rubygems.org'\ngem 'posthog'\n"], + ['go.mod', 'module x\nrequire github.com/posthog/posthog-go v1.2.0\n'], + ['pubspec.yaml', 'dependencies:\n posthog: ^4.0.0\n'], + ]; + for (const [name, contents] of cases) { + const dir = makeTmpDir(); + try { + fs.writeFileSync(path.join(dir, name), contents); + expect(detectPostHogPresent(dir), name).toBe(true); + } finally { + cleanup(dir); + } + } + }); + it('returns false for an empty project', () => { const dir = makeTmpDir(); try { diff --git a/src/lib/programs/self-driving/ARCHITECTURE.md b/src/lib/programs/self-driving/ARCHITECTURE.md index 018cd9b1..10645aef 100644 --- a/src/lib/programs/self-driving/ARCHITECTURE.md +++ b/src/lib/programs/self-driving/ARCHITECTURE.md @@ -29,6 +29,7 @@ prefix are in `wizard`; cross-repo paths are prefixed `posthog/…` / `context-m | Why a team gets no findings | §6 | | What to change for prod | §7 | | Local dev + reset | §8 | +| Proactive product enablement (planned) | §9 | --- @@ -423,6 +424,11 @@ Plus the **Temporal coordinator schedule** (`signals-scout-coordinator-schedule` > predicate; (c) **no deck** (Tips from the start) — needs a generic "empty deck ⇒ complete > immediately" guard in `LearnCard` / `RunScreen`, because an empty `getContentBlocks` never fires > `onSequenceComplete` and would otherwise hang on a blank Learn pane. UI polish — deferred. +> 13. **Proactive product enablement (replay / error tracking / support).** A new "Enable products" +> step turns products ON (web server-flip) **before** STEP 4 enables their sources — via an +> intent-based `products-enable` MCP tool (one narrow `product_enablement:write` scope, +> server-owned recipes) instead of `project:write`; Support is flag-on + a report CTA. Full design, +> decisions, telemetry, and the cross-repo work list are in **§9**. --- @@ -448,6 +454,142 @@ the wizard log. `DEBUG`-only. --- +## 9. Planned: proactive product enablement (replay / error tracking / support) + +> [!IMPORTANT] +> **PLANNED — not yet implemented.** Design + cross-repo work list for a new step that turns PostHog +> products ON (so the signal sources have data to read) **before** STEP 4 enables the sources. Captured +> from the design session; spans **wizard + posthog + context-mill** (+ a one-time OAuth-ceiling edit). +> Symbol names are durable; `file:line` anchors are point-in-time. Keep in lockstep once it lands. + +### 9.1 Decisions (settled) + +- **New "Enable products" step** runs after §2 step 2 (*Read context*) and **before** STEP 4 (*Enable + sources*). It turns on **Session Replay** + **Error Tracking** every run; **Support/Conversations** is + flag-on + a report CTA only (9.4). **STEP 4 is unchanged** — once products are on, its existing "enable + sources for products in use" rule picks them up. +- **One path for everyone.** No free/paid fork, no consent prompt, no per-framework skill fork. +- **No billing writes.** The "$0 spend cap" idea is **dropped**: `custom_limits_usd` is org-wide, set via an + `INTERNAL`-scoped endpoint (`ee/api/billing.py`) unreachable by any OAuth token, and a $0 cap *harms* + existing paying users (caps + drops data across all their projects; `ee/billing/quota_limiting.py`). + `remote_config.py` even force-disables replay when recordings are quota-limited. Cost overruns are handled + **reactively (refunds)** — a product decision. +- **Transparency + PII.** No prompt, but the **report/outro discloses** what was enabled, and the replay + recipe **sets conservative masking** server-side. `recording_domains` defaults to *all domains incl. + production*, so masking is the safeguard. **TODO: verify the posthog-js default masking** first. +- **Web first.** A server-side flip only activates products for SDKs that read remote config (posthog-js). + Backend/mobile need generated **code** → phase 2 (9.6). + +### 9.2 Write path — intent-based `products-enable`, NOT `project:write` + +- **Not `project:write`:** it makes `ProjectViewSet` (a `ModelViewSet`, `scope_object="project"`) writable → + authorizes `DELETE /api/projects/:id` + ~60 team fields incl. `access_control` (RBAC), + `session_recording_masking_config` (PII), `app_urls`, `test_account_filters` (`posthog/api/project.py` + `team_passthrough_fields`). Every *existing* wizard write scope is a product-object write — none can delete + the project or rewrite security/privacy config. It's also a **permanent, org-wide ceiling** change on a + **public npm** tool (every external user grants it), and breaks self-driving's "read-only + narrow product + writes" property. +- **Not per-product settings viewsets:** doesn't scale — each new product (logs, heatmaps, surveys…) = + another viewset + tool + scope. (Precedent that *does* work this way: `ErrorTrackingSettingsViewSet`, + `scope_object="error_tracking"`, in `posthog/products/error_tracking/backend/presentation/views/settings.py`.) +- **Chosen — one intent-based surface.** `products-enable {products: ProductKey[]}`, gated by **one** new + narrow scope **`product_enablement:write`**. The caller names *which* products; the **server owns the + recipe** per product (toggle + companion defaults). The caller passes **no field values**, so it cannot + weaken masking or set bad limits. **Adding a product later = register a recipe + add an enum key** — no + wizard/scope/ceiling change. Most enable-levers are flat Team opt-in bools in the same + `team_passthrough_fields` list (`heatmaps_opt_in`, `surveys_opt_in`, `capture_console_log_opt_in`, + `capture_performance_opt_in`, `autocapture_opt_out`, `session_recording_opt_in`, + `autocapture_exceptions_opt_in`, `conversations_enabled`), so one surface covers them all. + +### 9.3 Recipes (server-owned, posthog) + +A thin `ProductEnablementViewSet` iterates the requested keys → dispatches to a per-product recipe each +product module registers. Primary toggle is **always** set; companion settings are applied **only if unset** +(don't clobber a user's existing custom config). Examples: + +- `session_replay` → `team.session_recording_opt_in = True`; if `session_recording_masking_config` unset → + default masking. (`posthog/models/team/team.py:361`.) +- `error_tracking` → `team.autocapture_exceptions_opt_in = True`; `ErrorTrackingSettings.objects.get_or_create` + for default limits. (`team.py:466`; `ErrorTrackingSettings` model defaults, `error_tracking/backend/models.py:604`.) +- `conversations` (later) → `team.conversations_enabled = True` (`team.py:438`). + +Open: whether replay should **always enforce** a masking floor vs apply-if-unset (per-recipe choice). + +### 9.4 Mechanism facts (verified — don't re-derive) + +- **Replay (web):** `session_recording_opt_in=true` → `remote_config.py:262` emits the `sessionRecording` + block → posthog-js `isRecordingEnabled = window && serverEnabled && !disable_session_recording && !optedOut` + → records **on next page load**. No code change for a default-config web SDK. +- **Error tracking (web):** `autocapture_exceptions_opt_in=true` → `remote_config.py:240` emits + `autocaptureExceptions` → posthog-js hooks `window.onerror`/rejections (uses the remote flag when the client + `capture_exceptions` is unset). No code change. +- **The step CHECKS (and edits) the init snippet:** if the wizard's posthog-js init set + `disable_session_recording: true` / `capture_exceptions: false`, the client overrides the remote flag and + the flip is **inert**. Phase-1 reads the init in the user's repo and edits it to not override (warlock/YARA + scans apply to the edit). Snippet content lives in context-mill (off-disk). +- **Backend/mobile:** the Team flags are **inert** (no posthog-js to read them) → phase 2. +- **Conversations is inert as a flag flip:** the `conversations` signal source reads `Ticket` rows + (`products/signals/backend/emission/fetchers/conversations.py`); tickets are created only by a connected + channel — widget/email/Slack/Teams (`create_with_number`, `products/conversations/backend/signals.py:79`). + So phase 1 = flip `conversations_enabled` (cheap, "eases the start") + enable the `conversations` + `SignalSourceConfig` (already callable, `task:write`) + a **report CTA** ("connect a channel"). Real fix = + the widget embed (phase 2). NB enabling auto-generates `widget_public_token` (`team.py:2367`) but leaves + `widget_enabled=false`. + +### 9.5 Framework coverage (90-day wizard telemetry, `internal-j`) + +Break `wizard: setup confirmed` down by `properties.integration` (on the terminal `setup wizard finished` +event, `integration` is nested under `properties.tags`). Buckets: + +- **Web ~58%** (nextjs 41%, react-router, astro, tanstack-*, vue, sveltekit, nuxt, angular, javascript_web) + → **covered by the phase-1 server flip** (client replay + client errors). +- **Pure backend ~23%** (javascript_node **16.8%**, fastapi, python, flask, ruby) → flip is a **no-op**; + needs code (phase 2). +- **Mobile ~14%** (react-native **10.1%**, swift, android) → no-op; SDK-init code (phase 2). +- **Hybrid ~3%** (laravel, django, rails) → partial (only if they load posthog-js). Undetected ~4%. + +**Phase-2 priority is node-first** (16.8% — the single biggest slice the flip misses), then react-native. + +### 9.6 Phase 2 (deferred — context-mill, no new posthog scope) + +The backend/mobile lever is generated **code** (the agent already edits the repo), so it's a context-mill +skill change, not platform work: + +- Backend error tracking, **node-first** → python/fastapi/flask → ruby/php: enable exception autocapture in + the SDK init (e.g. python `enable_exception_autocapture=True`). +- Mobile (RN/swift/android): SDK-init replay + exception options (min-version-gated). +- **Support widget embed** (web): inject the widget snippet + `widget_enabled` → makes Conversations actually + produce tickets (upgrades 9.4's CTA to auto-done). +- Server-side error tracking for full-stack web (e.g. Next.js API routes via posthog-node). + +### 9.7 Cross-repo work list + +- **posthog:** `ProductEnablementViewSet` + `products-enable` MCP tool + per-product recipes + (replay/error-tracking now; conversations later) + new scope object `product_enablement` (`posthog/scopes.py`). + **Admin-RBAC decision:** the new route bypasses the `field_access_control('project','admin')` check + (enforced only in `project.py`) — relax for an opt-in enable, or replicate. +- **OAuth ceiling (manual, both regions):** add `product_enablement:write` to the wizard OAuth app + `OAuthApplication.scopes` — US prod client `c4Rdw8DIxgtQfA80IiSnGKlNX8QN00cFWF00QQhM` + dev + `DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ`. Net-new (mechanics: §7 item 1). +- **wizard:** add `product_enablement:write` to `SELF_DRIVING_SCOPE_ADDITIONS` (`program-scopes.ts`); new + "Enable products" step in `prompt.ts` (detect web via `session.integration` → call `products-enable` with + the list → check/edit the posthog-js init; backend/mobile skip + record for the report). Keep the existing + replay/exception opt-in reads (idempotency); the `customer_id` consent threading is **not** needed. Update + the §2 step backbone + this doc. +- **context-mill:** new skill ref `…-enable-products.md` before `4-sources.md`; update `description.md` step + list (→ 9 steps); `7-report.md` adds the "enabled products" disclosure + the Conversations CTA. + +### 9.8 Open items + +- Verify posthog-js **default masking**; decide always-enforce vs apply-if-unset (9.3). +- **Admin-RBAC** bypass decision (9.7). +- **Logs** likely isn't a Team opt-in toggle (OTel ingestion, "on when data arrives") — verify before adding a recipe. +- Idempotent enable; silent re-enable **will re-enable a setting a user turned off deliberately** (the flag + default `False` can't distinguish "never set" from "off on purpose") — accepted under "enable everyone." +- Refund operational process (no consent, no cap). + +--- + ## Cross-references - Wizard design discipline: repo-root `CLAUDE.md`, `.claude/skills/wizard-development/`. diff --git a/src/lib/programs/self-driving/detect.ts b/src/lib/programs/self-driving/detect.ts index 4e956890..a81bbb14 100644 --- a/src/lib/programs/self-driving/detect.ts +++ b/src/lib/programs/self-driving/detect.ts @@ -35,6 +35,10 @@ export const POSTHOG_PRESENT_KEY = 'postHogPresent'; */ export const SELF_DRIVING_INTEGRATE_PATH_KEY = 'selfDrivingIntegratePath'; +// Matches `posthog` at a dependency boundary (line start, or after a +// quote/slash/=/space), so it skips the substring glued inside another word. +const POSTHOG_PACKAGE_RE = /(^|["'\s/=])posthog/im; + /** * Deterministic, offline check: does the project already have a PostHog SDK? * Scans the common dependency manifests at the install dir for a `posthog` @@ -57,7 +61,7 @@ export function detectPostHogPresent(installDir: string): boolean { const path = join(installDir, name); if (!existsSync(path)) continue; try { - if (/posthog/i.test(readFileSync(path, 'utf8'))) return true; + if (POSTHOG_PACKAGE_RE.test(readFileSync(path, 'utf8'))) return true; } catch { /* unreadable — ignore */ } From 2f5b1e3ab9be93cb943f087353cb0d29cee5c78a Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Tue, 30 Jun 2026 15:09:05 +0200 Subject: [PATCH 3/3] fix: Check base directories. --- .../__tests__/self-driving-detect.test.ts | 28 +++++++++++++++++++ src/lib/programs/self-driving/detect.ts | 19 ++++++++----- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/lib/programs/__tests__/self-driving-detect.test.ts b/src/lib/programs/__tests__/self-driving-detect.test.ts index a9c881c5..b0308656 100644 --- a/src/lib/programs/__tests__/self-driving-detect.test.ts +++ b/src/lib/programs/__tests__/self-driving-detect.test.ts @@ -192,6 +192,34 @@ describe('detectPostHogPresent', () => { } }); + it('detects PostHog in a common sub-app dir (monorepo)', () => { + const dir = makeTmpDir(); + try { + fs.mkdirSync(path.join(dir, 'frontend')); + fs.writeFileSync( + path.join(dir, 'frontend', 'package.json'), + '{"dependencies":{"posthog-js":"^1.0.0"}}', + ); + expect(detectPostHogPresent(dir)).toBe(true); + } finally { + cleanup(dir); + } + }); + + it('does not scan arbitrary nested dirs (no recursive walk)', () => { + const dir = makeTmpDir(); + try { + fs.mkdirSync(path.join(dir, 'services', 'api'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'services', 'api', 'package.json'), + '{"dependencies":{"posthog-node":"^4.0.0"}}', + ); + expect(detectPostHogPresent(dir)).toBe(false); + } finally { + cleanup(dir); + } + }); + it('returns false for an empty project', () => { const dir = makeTmpDir(); try { diff --git a/src/lib/programs/self-driving/detect.ts b/src/lib/programs/self-driving/detect.ts index a81bbb14..c5a638c5 100644 --- a/src/lib/programs/self-driving/detect.ts +++ b/src/lib/programs/self-driving/detect.ts @@ -57,13 +57,18 @@ export function detectPostHogPresent(installDir: string): boolean { 'composer.json', 'pubspec.yaml', ]; - for (const name of manifests) { - const path = join(installDir, name); - if (!existsSync(path)) continue; - try { - if (POSTHOG_PACKAGE_RE.test(readFileSync(path, 'utf8'))) return true; - } catch { - /* unreadable — ignore */ + // Also check a few common sub-app dirs so monorepos (frontend/, backend/) + // are caught; deliberately not a recursive tree walk. + const dirs = ['.', 'app', 'frontend', 'backend']; + for (const dir of dirs) { + for (const name of manifests) { + const path = join(installDir, dir, name); + if (!existsSync(path)) continue; + try { + if (POSTHOG_PACKAGE_RE.test(readFileSync(path, 'utf8'))) return true; + } catch { + /* unreadable — ignore */ + } } } return false;