diff --git a/src/__tests__/mcp-cli.test.ts b/src/__tests__/mcp-cli.test.ts index f604c2fe2..e9b41f8b3 100644 --- a/src/__tests__/mcp-cli.test.ts +++ b/src/__tests__/mcp-cli.test.ts @@ -17,6 +17,7 @@ vi.mock('@lib/wizard-session', () => ({ // suite's assertions, stubbed only so the mocked module still satisfies // the real module's exports. reportableDiscoveredFeatures: () => undefined, + reportablePosthogSdkDetected: () => undefined, })); vi.mock('@ui/tui/start-tui', () => ({ startTUI: mockStartTUIMcp, diff --git a/src/lib/programs/__tests__/posthog-integration-detect.test.ts b/src/lib/programs/__tests__/posthog-integration-detect.test.ts new file mode 100644 index 000000000..7249ee81e --- /dev/null +++ b/src/lib/programs/__tests__/posthog-integration-detect.test.ts @@ -0,0 +1,71 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { detectExistingPostHog } from '@lib/programs/posthog-integration/detect'; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ph-detect-')); +} + +function writePackageJson( + dir: string, + pkg: { + dependencies?: Record; + devDependencies?: Record; + } = {}, +): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(pkg)); +} + +describe('detectExistingPosthog', () => { + let tmpDir: string; + let setPosthogSdkDetected: ReturnType; + + beforeEach(() => { + tmpDir = makeTmpDir(); + setPosthogSdkDetected = vi.fn(); + }); + + afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true })); + + const run = (dir: string) => + detectExistingPostHog({ setPosthogSdkDetected }, dir); + + it('reports false when no package.json exists', () => { + run(tmpDir); + expect(setPosthogSdkDetected).toHaveBeenCalledWith(false); + }); + + it('reports false when dependencies have no PostHog SDK', () => { + writePackageJson(tmpDir, { dependencies: { react: '^19.0.0' } }); + run(tmpDir); + expect(setPosthogSdkDetected).toHaveBeenCalledWith(false); + }); + + it('reports true for posthog-js in dependencies', () => { + writePackageJson(tmpDir, { dependencies: { 'posthog-js': '^1.0.0' } }); + run(tmpDir); + expect(setPosthogSdkDetected).toHaveBeenCalledWith(true); + }); + + it('reports true for posthog-node in devDependencies', () => { + writePackageJson(tmpDir, { devDependencies: { 'posthog-node': '^4.0.0' } }); + run(tmpDir); + expect(setPosthogSdkDetected).toHaveBeenCalledWith(true); + }); + + it('reports true for a PostHog SDK in a nested monorepo package', () => { + writePackageJson(tmpDir, { dependencies: {} }); + writePackageJson(path.join(tmpDir, 'apps', 'web'), { + dependencies: { 'posthog-js': '^1.0.0' }, + }); + run(tmpDir); + expect(setPosthogSdkDetected).toHaveBeenCalledWith(true); + }); + + it('does not throw and reports false for an invalid install dir', () => { + expect(() => run('/nonexistent/path')).not.toThrow(); + expect(setPosthogSdkDetected).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/lib/programs/__tests__/program-registry.test.ts b/src/lib/programs/__tests__/program-registry.test.ts index 4a1baa37a..90f3e472d 100644 --- a/src/lib/programs/__tests__/program-registry.test.ts +++ b/src/lib/programs/__tests__/program-registry.test.ts @@ -1,6 +1,8 @@ import { PROGRAM_REGISTRY, agentSkillConfig, + getCommandPath, + getLaunchablePrograms, getProgramConfig, getSubcommandPrograms, } from '@lib/programs/program-registry'; @@ -34,7 +36,6 @@ describe('getSubcommandPrograms', () => { const subcommands = getSubcommandPrograms(); const commands = subcommands.map((c) => c.command); - expect(commands).toContain('integrate'); expect(commands).toContain('revenue-analytics'); for (const config of subcommands) { expect(config.command).toBeTruthy(); @@ -42,6 +43,55 @@ describe('getSubcommandPrograms', () => { }); }); +// A nested program is only reachable through its parent's word. +describe('getCommandPath', () => { + const subcommand = (id: string) => + getSubcommandPrograms().find((config) => config.id === id)!; + + it('reaches a nested program through its parent', () => { + expect(getCommandPath(subcommand('web-analytics-doctor'))).toBe( + 'audit web-analytics', + ); + }); + + it('leaves a top-level program alone', () => { + expect(getCommandPath(subcommand('revenue-analytics-setup'))).toBe( + 'revenue-analytics', + ); + }); +}); + +describe('getLaunchablePrograms', () => { + // The list is curated, so an id that stops matching drops its row in silence. + it("offers the intro's programs, in order, all resolving", () => { + expect(getLaunchablePrograms().map((config) => config.id)).toEqual([ + 'self-driving', + 'error-tracking-upload-source-maps', + 'warehouse-source', + 'audit', + 'posthog-doctor', + 'mcp-analytics', + 'replay-vision', + 'ai-observability', + 'metrics', + 'revenue-analytics-setup', + ]); + }); + + // A row wider than the terminal stops the whole block from centering. + it('keeps every row inside an 80-column terminal', () => { + const COMMAND_COLUMN = 21; + const MARKER_PREFIX = 2; + const BUDGET = 80 - COMMAND_COLUMN - MARKER_PREFIX; + + const tooLong = getLaunchablePrograms() + .filter((config) => config.description.length > BUDGET) + .map((config) => `${config.id} (${config.description.length})`); + + expect(tooLong).toEqual([]); + }); +}); + describe('parentCommand nesting', () => { it('nests web-analytics-doctor under the audit command', () => { const webAnalytics = getProgramConfig('web-analytics-doctor'); diff --git a/src/lib/programs/audit/index.ts b/src/lib/programs/audit/index.ts index 63c41032a..ce784d82b 100644 --- a/src/lib/programs/audit/index.ts +++ b/src/lib/programs/audit/index.ts @@ -36,8 +36,7 @@ const baseConfig = createSkillProgram({ skillId: 'audit', command: 'audit', id: 'audit', - description: - 'Audit an existing PostHog integration for correctness and best practices', + description: 'Audit and improve your PostHog setup', integrationLabel: 'audit', customPrompt: 'Run a comprehensive audit of the existing PostHog integration. Follow the skill program steps in order. Do not modify any project files — only create the final audit report.', diff --git a/src/lib/programs/events-audit/index.ts b/src/lib/programs/events-audit/index.ts index f6937b2b5..b80fad1ee 100644 --- a/src/lib/programs/events-audit/index.ts +++ b/src/lib/programs/events-audit/index.ts @@ -19,8 +19,13 @@ export { SETUP_REPORT_FILE }; const DOCS_URL = 'https://posthog.com/docs/product-analytics/best-practices'; +/** + * No CLI word of its own since the audit family took over: `wizard audit + * events` is the live path, and it resolves to the context-mill `audit-events` + * skill (whose id AuditRunScreen keys its slides on), not to this config. + * Registered so its id stays resolvable; nothing dispatches to it today. + */ export const eventsAuditConfig: ProgramConfig = { - command: 'events-audit', description: 'Audit PostHog event tracking in this project', id: 'events-audit', skillId: 'events-audit', diff --git a/src/lib/programs/mcp-analytics/index.ts b/src/lib/programs/mcp-analytics/index.ts index d5ed1812c..f9199d76e 100644 --- a/src/lib/programs/mcp-analytics/index.ts +++ b/src/lib/programs/mcp-analytics/index.ts @@ -58,7 +58,7 @@ export const mcpAnalyticsConfig = createSkillProgram({ skillId: 'mcp-analytics', command: 'mcp-analytics', id: 'mcp-analytics', - description: 'Add PostHog MCP analytics to your MCP server', + description: 'Add PostHog MCP Analytics to your MCP server', integrationLabel: 'mcp-analytics', customPrompt: "Instrument this project's MCP server with PostHog MCP analytics. Run the " + diff --git a/src/lib/programs/posthog-doctor/index.ts b/src/lib/programs/posthog-doctor/index.ts index 828b6fb4b..8ea70acf1 100644 --- a/src/lib/programs/posthog-doctor/index.ts +++ b/src/lib/programs/posthog-doctor/index.ts @@ -4,8 +4,7 @@ import { POSTHOG_DOCTOR_PROGRAM } from './steps.js'; export const posthogDoctorConfig: ProgramConfig = { command: 'doctor', - description: - 'Diagnose your PostHog project for configuration issues and setup warnings', + description: 'Diagnose your PostHog project setup', id: 'posthog-doctor', requiresAi: false, steps: POSTHOG_DOCTOR_PROGRAM, diff --git a/src/lib/programs/posthog-integration/__tests__/detect.test.ts b/src/lib/programs/posthog-integration/__tests__/detect.test.ts index d53780c1c..3e4a67f56 100644 --- a/src/lib/programs/posthog-integration/__tests__/detect.test.ts +++ b/src/lib/programs/posthog-integration/__tests__/detect.test.ts @@ -59,6 +59,7 @@ function makeCtx(session: WizardSession): ProgramReadyContext { }, setFrameworkConfig: vi.fn(), setDetectedFramework: vi.fn(), + setPosthogSdkDetected: vi.fn(), setSkillId: vi.fn(), setUnsupportedVersion: vi.fn(), addDiscoveredFeature: vi.fn(), diff --git a/src/lib/programs/posthog-integration/detect.ts b/src/lib/programs/posthog-integration/detect.ts index 1efab12a3..f27faa6a9 100644 --- a/src/lib/programs/posthog-integration/detect.ts +++ b/src/lib/programs/posthog-integration/detect.ts @@ -31,6 +31,7 @@ import { DETECTED_WAREHOUSE_SOURCES_KEY, getDetectedWarehouseSources, } from '@lib/programs/warehouse-source/detect'; +import { findPackageJsons } from '@lib/programs/shared/package-scanning'; export async function detectPostHogIntegration( ctx: ProgramReadyContext, @@ -84,6 +85,7 @@ export async function detectPostHogIntegration( } detectWarehouseSourcesForSuggestion(ctx, installDir); + detectExistingPostHog(ctx, installDir); ctx.setDetectionComplete(); } @@ -251,3 +253,20 @@ export function reportWarehouseSourcesDetected( return true; } + +/** Dependency-level signal, not a verified install. A failed scan reports false. */ +export function detectExistingPostHog( + ctx: Pick, + installDir: string, +): void { + try { + const pkgJsons = findPackageJsons(installDir); + ctx.setPosthogSdkDetected(pkgJsons.some((p) => p.posthogSdks.length > 0)); + } catch (error) { + analytics.captureException( + error instanceof Error ? error : new Error(String(error)), + { step: 'detectExistingPosthog' }, + ); + ctx.setPosthogSdkDetected(false); + } +} diff --git a/src/lib/programs/posthog-integration/index.ts b/src/lib/programs/posthog-integration/index.ts index 7a90f313e..9d89e77df 100644 --- a/src/lib/programs/posthog-integration/index.ts +++ b/src/lib/programs/posthog-integration/index.ts @@ -189,7 +189,6 @@ export const SETUP_REPORT_FILE = 'posthog-setup-report.md'; export { EVENT_PLAN_FILE } from './constants.js'; export const posthogIntegrationConfig: ProgramConfig = { - command: 'integrate', description: 'Set up PostHog SDK integration', id: 'posthog-integration', agentFlow: 'integration-v2', diff --git a/src/lib/programs/program-registry.ts b/src/lib/programs/program-registry.ts index 2633c295e..56389efc2 100644 --- a/src/lib/programs/program-registry.ts +++ b/src/lib/programs/program-registry.ts @@ -135,3 +135,32 @@ export function getSubcommandPrograms(): SubcommandProgram[] { (c): c is SubcommandProgram => c.command != null, ); } + +/** What a user types to reach the program. Nested ones go through its parent. */ +export function getCommandPath(config: SubcommandProgram): string { + return config.parentCommand + ? `${config.parentCommand} ${config.command}` + : config.command; +} + +/** What the intro offers, in order. Curated: no config field ranks these. */ +const INTRO_PROGRAMS = [ + 'self-driving', + 'error-tracking-upload-source-maps', + 'warehouse-source', + 'audit', + 'posthog-doctor', + 'mcp-analytics', + 'replay-vision', + 'ai-observability', + 'metrics', + 'revenue-analytics-setup', +]; + +/** The programs the intro can hand off to, in the order it lists them. */ +export function getLaunchablePrograms(): SubcommandProgram[] { + const byId = new Map(getSubcommandPrograms().map((c) => [c.id, c])); + return INTRO_PROGRAMS.map((id) => byId.get(id)).filter( + (config): config is SubcommandProgram => config != null, + ); +} diff --git a/src/lib/programs/program-step.ts b/src/lib/programs/program-step.ts index 53460b7f7..064bbb2d9 100644 --- a/src/lib/programs/program-step.ts +++ b/src/lib/programs/program-step.ts @@ -59,6 +59,7 @@ export interface ProgramReadyContext { }) => void; readonly addDiscoveredFeature: (feature: DiscoveredFeature) => void; readonly setDetectionComplete: () => void; + readonly setPosthogSdkDetected: (detected: boolean) => void; } export interface ProgramStep { diff --git a/src/lib/programs/replay-vision/index.ts b/src/lib/programs/replay-vision/index.ts index 6c1cca047..6688cc070 100644 --- a/src/lib/programs/replay-vision/index.ts +++ b/src/lib/programs/replay-vision/index.ts @@ -119,7 +119,7 @@ const base = createSkillProgram({ skillId: 'replay-vision-setup', command: 'replay-vision', id: 'replay-vision', - description: 'Set up PostHog Replay vision scanners for your product', + description: 'Set up PostHog Replay Vision scanners for your product', integrationLabel: 'replay-vision', customPrompt: 'Set up PostHog Replay vision. Run the `replay-vision` skill end-to-end: ' + diff --git a/src/lib/programs/revenue-analytics/index.ts b/src/lib/programs/revenue-analytics/index.ts index 27a30ffa2..f75778bd1 100644 --- a/src/lib/programs/revenue-analytics/index.ts +++ b/src/lib/programs/revenue-analytics/index.ts @@ -6,7 +6,7 @@ import { getContentBlocks } from './content/index.js'; export const revenueAnalyticsConfig: ProgramConfig = { command: 'revenue-analytics', - description: 'Set up PostHog revenue analytics (e.g. Stripe integration)', + description: 'Set up PostHog for Revenue Analytics', id: 'revenue-analytics-setup', skillId: 'revenue-analytics-setup', steps: REVENUE_ANALYTICS_PROGRAM, diff --git a/src/lib/programs/warehouse-source/index.ts b/src/lib/programs/warehouse-source/index.ts index 8667a6b7e..8163e4589 100644 --- a/src/lib/programs/warehouse-source/index.ts +++ b/src/lib/programs/warehouse-source/index.ts @@ -42,8 +42,7 @@ function buildPrompt(session: WizardSession): string { export const warehouseSourceConfig: ProgramConfig = { command: 'warehouse', - description: - 'Detect and connect a data warehouse source (Postgres, Stripe, …)', + description: 'Detect and connect Data Warehouse sources', id: 'warehouse-source', skillId: 'data-warehouse-source-setup', steps: WAREHOUSE_SOURCE_PROGRAM, diff --git a/src/lib/runners/run-non-interactive.ts b/src/lib/runners/run-non-interactive.ts index a26263dbb..72a5e8629 100644 --- a/src/lib/runners/run-non-interactive.ts +++ b/src/lib/runners/run-non-interactive.ts @@ -236,6 +236,9 @@ export function runNonInteractive( }, addDiscoveredFeature: () => undefined, setDetectionComplete: () => undefined, + setPosthogSdkDetected: (detected: boolean) => { + session.posthogSdkDetected = detected; + }, }; for (const step of config.steps) { if (step.onReady) { diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index b2d915553..3ac193049 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -2,6 +2,7 @@ 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 { getProgramConfig } from '@lib/programs/program-registry'; import { maybeStampAiSdkDetected } from '@lib/programs/posthog-integration/detect'; import type { ProgramConfig } from '@lib/programs/program-step'; import type { Harness, Sequence } from '@lib/constants'; @@ -137,27 +138,11 @@ export function runWizard( activeTui.store.session = session; - const taskStreamEnabled = !session.noTelemetry; - taskStream = new TaskStreamPush({ - store: activeTui.store, - programId: config.id, - destinations: [ - new PostHogDestination({ - getCredentials: () => activeTui.store.session.credentials, - onError: (err) => logToFile('[task-stream-push]', err.message), - }), - ], - eventPlanPath: config.eventPlanFile - ? join(session.installDir, config.eventPlanFile) - : undefined, - enabled: taskStreamEnabled, - }); - const activeStream = taskStream; - activeStream.attach(); - // Flush a terminal-phase push on Ctrl-C so the web app sees the // run ended in error rather than hanging on the last "running" - // snapshot. + // snapshot. Registered before the stream exists: Ctrl-C on the intro + // must still restore the terminal and run the cleanups, and there is + // no run to report yet. let signalled = false; onSignal = (): void => { if (signalled || exitInProgress) return; @@ -169,27 +154,62 @@ export function runWizard( if (activeTui.store.session.runPhase === RunPhase.Running) { activeTui.store.setRunPhase(RunPhase.Error); } - void activeStream + const teardown = (): void => { + try { + activeTui.unmount(); + } catch { + // terminal may already be torn down + } + process.exit(130); + }; + const stream = taskStream; + if (!stream) { + teardown(); + return; + } + void stream .shutdown(2000) .catch((e) => logToFile('[run-wizard] task stream shutdown error on signal:', e), ) - .finally(() => { - try { - activeTui.unmount(); - } catch { - // terminal may already be torn down - } - process.exit(130); - }); + .finally(teardown); }; process.on('SIGINT', onSignal); 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'); + for (;;) { + await activeTui.store.runReadyHooks(); + // Settle the pre-run screens; `integration-check` is a no-op gate here. + await activeTui.store.getGate('intro'); + + const active = activeTui.store.router.activeProgram; + if (active === config.id) break; + config = getProgramConfig(active); + } + + // After the switch loop, not before: the stream bakes its program id, + // session id, and event-plan path in at construction, so a stream built + // for the launch program would report the whole run under a program the + // user left on the intro screen. Nothing before this point produces a + // task to push. + const taskStreamEnabled = !session.noTelemetry; + const activeStream = new TaskStreamPush({ + store: activeTui.store, + programId: config.id, + destinations: [ + new PostHogDestination({ + getCredentials: () => activeTui.store.session.credentials, + onError: (err) => logToFile('[task-stream-push]', err.message), + }), + ], + eventPlanPath: config.eventPlanFile + ? join(session.installDir, config.eventPlanFile) + : undefined, + enabled: taskStreamEnabled, + }); + taskStream = activeStream; + activeStream.attach(); + await activeTui.store.getGate('integration-check'); await activeTui.store.getGate('health-check'); diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 40a704aba..5c428c3ae 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -300,6 +300,9 @@ export interface WizardSession { /** Human-readable label for the detected framework variant (e.g., "Django with Wagtail CMS") */ detectedFrameworkLabel: string | null; + /** PostHog found in the project's dependencies. A signal, not a verified install. */ + posthogSdkDetected: boolean; + /** True once framework detection has run (whether it found something or not) */ detectionComplete: boolean; @@ -522,6 +525,7 @@ export function buildSession(args: { frameworkContext: {}, typescript: false, detectedFrameworkLabel: null, + posthogSdkDetected: false, detectionComplete: false, unsupportedVersion: null, @@ -579,3 +583,10 @@ export function reportableDiscoveredFeatures( ): DiscoveredFeature[] | undefined { return mayReportScanResults(session) ? session.discoveredFeatures : undefined; } + +/** Also a scan result, so it travels under the same consent as the rest. */ +export function reportablePosthogSdkDetected( + session: WizardSession, +): boolean | undefined { + return mayReportScanResults(session) ? session.posthogSdkDetected : undefined; +} diff --git a/src/ui/tui/__tests__/posthog-integration-intro.test.ts b/src/ui/tui/__tests__/posthog-integration-intro.test.ts new file mode 100644 index 000000000..c098d5ab2 --- /dev/null +++ b/src/ui/tui/__tests__/posthog-integration-intro.test.ts @@ -0,0 +1,118 @@ +import { + CONTINUE_ANYWAY_LABEL, + CONTINUE_LABEL, + DEFAULT_HEADLINE, + DETECTED_HEADLINE, + introHeadline, + introMenuOptions, +} from '@ui/tui/posthog-integration-intro'; + +const valuesFor = (args: { + view?: 'default' | 'more-info' | 'commands'; + showContinue?: boolean; + posthogSdkDetected?: boolean; +}): string[] => + ( + introMenuOptions({ + view: args.view ?? 'default', + showContinue: args.showContinue ?? true, + posthogSdkDetected: args.posthogSdkDetected ?? false, + }) ?? [] + ).map((option) => option.value); + +describe('introHeadline', () => { + it('leads with the detection when PostHog is already installed', () => { + expect(introHeadline(true)).toEqual(DETECTED_HEADLINE); + }); + + it('keeps the default headline for a clean project', () => { + expect(introHeadline(false)).toEqual([DEFAULT_HEADLINE]); + }); + + // Both assertions above pass vacuously if the two ever collapse. + it('resolves the two states to different copy', () => { + expect(DETECTED_HEADLINE).not.toEqual([DEFAULT_HEADLINE]); + }); +}); + +describe('introMenuOptions', () => { + describe('an install we already detected', () => { + // Exploring outranks re-running an integration the project may not need. + it('offers the tricks before continuing', () => { + expect(valuesFor({ posthogSdkDetected: true })).toEqual([ + 'commands', + 'continue', + 'framework', + 'more-info', + 'cancel', + ]); + }); + + it('hedges the continue label', () => { + const options = introMenuOptions({ + view: 'default', + showContinue: true, + posthogSdkDetected: true, + }); + expect(options?.find((o) => o.value === 'continue')?.label).toBe( + CONTINUE_ANYWAY_LABEL, + ); + }); + }); + + describe('a clean project', () => { + it('is untouched by any of this', () => { + expect(valuesFor({ posthogSdkDetected: false })).toEqual([ + 'continue', + 'framework', + 'more-info', + 'cancel', + ]); + }); + + it('continues without the hedge', () => { + const options = introMenuOptions({ + view: 'default', + showContinue: true, + posthogSdkDetected: false, + }); + expect(options?.find((o) => o.value === 'continue')?.label).toBe( + CONTINUE_LABEL, + ); + }); + }); + + // Same vacuous-pass risk as the headline pair. + it('distinguishes the two continue labels', () => { + expect(CONTINUE_ANYWAY_LABEL).not.toBe(CONTINUE_LABEL); + }); + + describe('the sub-views', () => { + // A menu here would fight the body's picker for the arrow keys. + it('renders no menu under the command list', () => { + expect( + introMenuOptions({ + view: 'commands', + showContinue: true, + posthogSdkDetected: true, + }), + ).toBeNull(); + }); + + // IntroScreenLayout appends the disclosure row, so no view carries one. + it('gives the more-info view a way back and nothing else', () => { + expect(valuesFor({ view: 'more-info' })).toEqual(['back']); + }); + }); + + // Detecting, framework-picking and unsupported all clear showContinue. + it('renders no menu when there is nothing to continue to', () => { + expect( + introMenuOptions({ + view: 'default', + showContinue: false, + posthogSdkDetected: true, + }), + ).toBeNull(); + }); +}); diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index a58923606..ee8201a7d 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -18,6 +18,7 @@ import { buildSession } from '@lib/wizard-session'; import { HostResolution } from '@lib/host-resolution'; import { Integration } from '@lib/constants'; import { analytics } from '@utils/analytics'; +import { getProgramConfig } from '@lib/programs/program-registry'; vi.mock('../../../utils/analytics.js', () => ({ analytics: { @@ -93,6 +94,94 @@ describe('WizardStore', () => { expect(store.getVersion()).toBe(0); expect(store.getSnapshot()).toBe(0); }); + + // Runs another command in this session; nothing has happened yet to unwind. + describe('switchProgram', () => { + it('makes the chosen program the active one', () => { + const store = createStore(); + store.switchProgram(Program.Metrics); + expect(store.router.activeProgram).toBe(Program.Metrics); + }); + + it('routes to the new program instead of finishing the old one', () => { + const store = createStore(); + store.switchProgram(Program.Metrics); + expect(store.router.resolve(store.session)).toBe(ScreenId.MetricsIntro); + }); + + // Every program gates its intro on the same flag, so a stale one skips it. + it('does not carry the old confirmation into the new intro', () => { + const store = createStore(); + store.completeSetup(); + expect(store.session.setupConfirmed).toBe(true); + + store.switchProgram(Program.Metrics); + + expect(store.session.setupConfirmed).toBe(false); + expect(store.router.resolve(store.session)).toBe(ScreenId.MetricsIntro); + }); + + // Already resolved for the program we left, so reusing them skips screens. + it('reopens the gates for the new program', async () => { + const store = createStore(); + const before = store.getGate('intro'); + store.completeSetup(); + await expect(before).resolves.toBeUndefined(); + + store.switchProgram(Program.Metrics); + + const after = store.getGate('intro'); + expect(after).not.toBe(before); + await expect( + Promise.race([after, Promise.resolve('pending')]), + ).resolves.toBe('pending'); + }); + + // Dropping the promise the runner is parked on strands it, silently. + it('releases callers parked on the old gates', async () => { + const store = createStore(); + const parked = store.getGate('intro'); + + store.switchProgram(Program.Metrics); + + await expect(parked).resolves.toBeUndefined(); + }); + + it('reports screens under the new program', () => { + const store = createStore(); + store.switchProgram(Program.Metrics); + expect(store.analyticsProgramId).toBe(Program.Metrics); + }); + + // The run-level tag is stamped once at launch, so events after the + // switch would otherwise still carry the program the run started as. + it('retags the run with the new program', () => { + const store = createStore(); + store.switchProgram(Program.Metrics); + expect(analytics.setTag).toHaveBeenCalledWith( + 'program_id', + Program.Metrics, + ); + }); + + it('follows the new program for label and skill', () => { + const store = createStore(); + store.switchProgram(Program.Metrics); + expect(store.session.programLabel).toBe(Program.Metrics); + expect(store.session.skillId).toBe( + getProgramConfig(Program.Metrics).skillId ?? null, + ); + }); + + // Re-selecting the running program must not discard a fresh confirmation. + it('leaves the session alone when the program is unchanged', () => { + const store = createStore(); + store.completeSetup(); + store.switchProgram(Program.PostHogIntegration); + expect(store.session.setupConfirmed).toBe(true); + expect(store.router.activeProgram).toBe(Program.PostHogIntegration); + }); + }); }); // ── Change notification ────────────────────────────────────────── @@ -311,6 +400,13 @@ describe('WizardStore', () => { expect(store.session.detectionComplete).toBe(true); }); + it('setPosthogSdkDetected stores the verdict', () => { + const store = createStore(); + expect(store.session.posthogSdkDetected).toBe(false); + store.setPosthogSdkDetected(true); + expect(store.session.posthogSdkDetected).toBe(true); + }); + it('setDetectedFramework sets the label', () => { const store = createStore(); store.setDetectedFramework('Django'); diff --git a/src/ui/tui/posthog-integration-intro.ts b/src/ui/tui/posthog-integration-intro.ts new file mode 100644 index 000000000..15bc8b75a --- /dev/null +++ b/src/ui/tui/posthog-integration-intro.ts @@ -0,0 +1,52 @@ +import type { PickerOption } from '@ui/tui/primitives/index'; + +export type IntroMenuView = 'default' | 'more-info' | 'commands'; + +export const CONTINUE_LABEL = 'Continue'; +export const CONTINUE_ANYWAY_LABEL = 'Continue anyway'; + +export const DEFAULT_HEADLINE = "Let's do two hours of work in eight minutes."; +export const DETECTED_HEADLINE = [ + 'It looks like PostHog is already installed. The Wizard has many tricks ' + + 'up its sleeve, like auditing, uploading source maps, or making your ' + + 'product self-drive.', + 'You can still rerun the installation, but it might overwrite some of your work.', +]; + +export function introHeadline(posthogSdkDetected: boolean): string[] { + return posthogSdkDetected ? DETECTED_HEADLINE : [DEFAULT_HEADLINE]; +} + +export function introMenuOptions({ + view, + showContinue, + posthogSdkDetected, +}: { + view: IntroMenuView; + showContinue: boolean; + posthogSdkDetected: boolean; +}): PickerOption[] | null { + // Its body is a picker, and a second menu here would move both cursors. + if (view === 'commands') return null; + + if (view === 'more-info') { + return [{ label: 'Back', value: 'back' }]; + } + + if (showContinue) { + return [ + ...(posthogSdkDetected + ? [{ label: 'Explore spell book', value: 'commands' }] + : []), + { + label: posthogSdkDetected ? CONTINUE_ANYWAY_LABEL : CONTINUE_LABEL, + value: 'continue', + }, + { label: 'Change framework', value: 'framework' }, + { label: 'More info', value: 'more-info' }, + { label: 'Cancel', value: 'cancel' }, + ]; + } + + return null; +} diff --git a/src/ui/tui/primitives/PickerMenu.tsx b/src/ui/tui/primitives/PickerMenu.tsx index d9b43260c..33436be9f 100644 --- a/src/ui/tui/primitives/PickerMenu.tsx +++ b/src/ui/tui/primitives/PickerMenu.tsx @@ -294,7 +294,8 @@ const FilterRow = ({ shown: number; total: number; }) => ( - + // Indented to sit under PromptLabel, which carries a leading space. + {filter ? `Filter: ${filter} (${shown} of ${total})` diff --git a/src/ui/tui/router.ts b/src/ui/tui/router.ts index c9b887072..60a6669ff 100644 --- a/src/ui/tui/router.ts +++ b/src/ui/tui/router.ts @@ -50,8 +50,14 @@ export class WizardRouter { private overlays: Overlay[] = []; constructor(programId: ProgramId = Program.PostHogIntegration) { + this.setProgram(programId); + } + + /** Point the router at a different program. */ + setProgram(programId: ProgramId): void { this.programId = programId; this.sequence = PROGRAM_SEQUENCES[programId]; + this.overlays = []; } /** diff --git a/src/ui/tui/screens/IntroScreenLayout.tsx b/src/ui/tui/screens/IntroScreenLayout.tsx index 349816817..7e66cb37c 100644 --- a/src/ui/tui/screens/IntroScreenLayout.tsx +++ b/src/ui/tui/screens/IntroScreenLayout.tsx @@ -98,6 +98,29 @@ interface IntroScreenLayoutProps { errorView?: ReactNode; } +/** Pads to the widest label passed, so every tick lands in one column. */ +export function detectionLabelWidth(rows?: DetectionRow[]): number { + return Math.max( + 'Directory'.length, + ...(rows ?? []).map((row) => row.label.length), + ); +} + +const DetectionLine = ({ + label, + width, + children, +}: { + label: string; + width: number; + children: ReactNode; +}) => ( + + {label.padEnd(width)} {'✔'}{' '} + {children} + +); + const WizardTitle = ({ title }: { title: string }) => ( {'\u2588'} @@ -191,6 +214,8 @@ export const IntroScreenLayout = ({ privacyOptions, }); + const labelWidth = detectionLabelWidth(detectionRows); + const handleSelect = (value: string) => { if (value === 'privacy') return setShowingPrivacy(true); if (value === 'privacy-back') return setShowingPrivacy(false); @@ -249,40 +274,32 @@ export const IntroScreenLayout = ({ {showDetection && !showingPrivacy && ( - - - Directory {'\u2714'}{' '} - - - {'/'} - {path.basename(installDir)} - - + + {'/'} + {path.basename(installDir)} + {detectionRows?.map((row) => ( - - - {row.label} {'\u2714'}{' '} - - - {row.value} - {row.suffix ? ` ${row.suffix}` : ''} - - + + {row.value} + {row.suffix ? ` ${row.suffix}` : ''} + ))} {programLabel && ( - - Program{' '} - {'\u2714'} {programLabel} - + + {programLabel} + )} {programLabel === 'agent-skill' && skillId && ( - - Skill{' '} - {'\u2714'} {skillId} - + + {skillId} + )} )} diff --git a/src/ui/tui/screens/PostHogIntegrationIntroScreen.tsx b/src/ui/tui/screens/PostHogIntegrationIntroScreen.tsx index 227c6fc67..3da96b496 100644 --- a/src/ui/tui/screens/PostHogIntegrationIntroScreen.tsx +++ b/src/ui/tui/screens/PostHogIntegrationIntroScreen.tsx @@ -13,6 +13,10 @@ import type { ReactNode } from 'react'; import { useState, useSyncExternalStore } from 'react'; import type { WizardStore } from '@ui/tui/store'; import { Integration } from '@lib/constants'; +import { + getCommandPath, + getLaunchablePrograms, +} from '@lib/programs/program-registry'; import { PickerMenu, LoadingBox, @@ -21,11 +25,15 @@ import { import { IntroScreenLayout, type DetectionRow } from './IntroScreenLayout.js'; import { SkillSourceInfo, useSkillEntry } from './SkillSourceInfo.js'; import { ScanConsent } from '@lib/wizard-session'; +import { KeyMatch, useKeyBindings } from '@ui/tui/hooks/useKeyBindings'; import { Icons } from '@ui/tui/styles'; import { analytics } from '@utils/analytics'; import { PRIVACY_PANEL_LABEL } from '@ui/tui/components/PrivacyPanel'; - -type View = 'default' | 'more-info'; +import type { IntroMenuView } from '@ui/tui/posthog-integration-intro'; +import { + introHeadline, + introMenuOptions, +} from '@ui/tui/posthog-integration-intro'; /** * Replaces IntroScreenLayout's DEFAULT_SUBTITLE for this screen only. The @@ -44,18 +52,6 @@ const SUBTITLE = ( ); -/** - * Exported so a test can measure every label against the menu's column. - * `Privacy & data` is not here: IntroScreenLayout appends it to every intro - * menu, so no screen carries its own copy. - */ -export const CONTINUE_MENU_OPTIONS: { label: string; value: string }[] = [ - { label: 'Continue', value: 'continue' }, - { label: 'Change framework', value: 'framework' }, - { label: 'More info', value: 'more-info' }, - { label: 'Cancel', value: 'cancel' }, -]; - /** * A blank, unselectable row. Navigation skips disabled options, so this is a * margin the menu can hold rather than one the layout has to special-case. @@ -131,7 +127,7 @@ export const PostHogIntegrationIntroScreen = ({ const [pickingFramework, setPickingFramework] = useState(false); const [manuallySelected, setManuallySelected] = useState(false); - const [view, setView] = useState('default'); + const [view, setView] = useState('default'); const { session } = store; const sharing = session.scanConsent !== ScanConsent.Declined; @@ -150,6 +146,21 @@ export const PostHogIntegrationIntroScreen = ({ view === 'default' && !unsupported; + // The only view with no menu to carry a Back row, so Esc is its way out. + useKeyBindings( + 'posthog-integration-intro', + view === 'commands' + ? [ + { + match: KeyMatch.Escape, + label: 'esc', + action: 'back', + handler: () => setView('default'), + }, + ] + : [], + ); + // ── Title ────────────────────────────────────────────────────────── const title = detecting ? 'PostHog Wizard starting up' : 'PostHog Wizard 🦔'; @@ -218,10 +229,36 @@ export const PostHogIntegrationIntroScreen = ({ ); + } else if (view === 'commands') { + body = ( + ({ + label: `${getCommandPath(program).padEnd(21)}${program.description}`, + value: program.id, + }))} + onSelect={(value) => { + const id = Array.isArray(value) ? value[0] : value; + analytics.wizardCapture('intro menu selected', { value: id, view }); + store.switchProgram(id); + }} + /> + ); } else if (showContinue) { + const paragraphs = introHeadline(session.posthogSdkDetected); body = ( - - Let's do two hours of work in eight minutes. + 1 ? undefined : 'center'} + > + {paragraphs.map((paragraph, i) => ( + + {paragraph} + + ))} ); } @@ -243,6 +280,13 @@ export const PostHogIntegrationIntroScreen = ({ }); } + if (session.posthogSdkDetected) { + detectionRows.push({ + label: 'PostHog', + value: 'detected in package.json', + }); + } + // ── Children (between rows and menu) ─────────────────────────────── let bodyChildren: ReactNode = null; @@ -285,14 +329,11 @@ export const PostHogIntegrationIntroScreen = ({ // ── Menu ─────────────────────────────────────────────────────────── - let menuOptions: PickerOption[] | null = null; - - if (view === 'more-info') { - // No route to the panel from here: it has its own top-level menu item. - menuOptions = [{ label: 'Back', value: 'back' }]; - } else if (showContinue) { - menuOptions = CONTINUE_MENU_OPTIONS; - } + const menuOptions = introMenuOptions({ + view, + showContinue, + posthogSdkDetected: session.posthogSdkDetected, + }); const handleSelect = (value: string) => { analytics.wizardCapture('intro menu selected', { value, view }); @@ -303,6 +344,8 @@ export const PostHogIntegrationIntroScreen = ({ setManuallySelected(true); } else if (value === 'more-info') { setView('more-info'); + } else if (value === 'commands') { + setView('commands'); } else if (value === 'back') { setView('default'); } else if (value === 'share') { diff --git a/src/ui/tui/screens/__tests__/IntroScreenLayout.test.ts b/src/ui/tui/screens/__tests__/IntroScreenLayout.test.ts index 6f6d7a2d7..01507c4cc 100644 --- a/src/ui/tui/screens/__tests__/IntroScreenLayout.test.ts +++ b/src/ui/tui/screens/__tests__/IntroScreenLayout.test.ts @@ -5,7 +5,10 @@ * nesting under More info. */ -import { buildIntroMenu } from '@ui/tui/screens/IntroScreenLayout'; +import { + buildIntroMenu, + detectionLabelWidth, +} from '@ui/tui/screens/IntroScreenLayout'; import { PRIVACY_PANEL_LABEL } from '@ui/tui/components/PrivacyPanel'; const values = (options: ReturnType) => @@ -90,3 +93,18 @@ describe('buildIntroMenu', () => { expect(plain?.at(-1)?.icon).toBeUndefined(); }); }); + +// Ticks used to sit in whatever column each label happened to end at. +describe('detectionLabelWidth', () => { + it('holds the Directory column when rows are shorter', () => { + expect(detectionLabelWidth([{ label: 'PostHog', value: 'yes' }])).toBe( + 'Directory'.length, + ); + }); + + it('widens for a row label longer than any the layout owns', () => { + expect(detectionLabelWidth([{ label: 'PostHog SDK', value: 'x' }])).toBe( + 'PostHog SDK'.length, + ); + }); +}); diff --git a/src/ui/tui/screens/__tests__/PostHogIntegrationIntroScreen.test.ts b/src/ui/tui/screens/__tests__/PostHogIntegrationIntroScreen.test.ts index 301b84ec6..a2def44d8 100644 --- a/src/ui/tui/screens/__tests__/PostHogIntegrationIntroScreen.test.ts +++ b/src/ui/tui/screens/__tests__/PostHogIntegrationIntroScreen.test.ts @@ -6,10 +6,8 @@ * one character too long. */ -import { - CONTINUE_MENU_OPTIONS, - sharingOptions, -} from '@ui/tui/screens/PostHogIntegrationIntroScreen'; +import { sharingOptions } from '@ui/tui/screens/PostHogIntegrationIntroScreen'; +import { introMenuOptions } from '@ui/tui/posthog-integration-intro'; // IntroScreenLayout renders a centered menu in a 24-column box. A row with an // icon spends four columns before the label: focus marker, gap, glyph, gap. @@ -17,13 +15,19 @@ const MENU_BOX_WIDTH = 24; const ICON_ROW_PREFIX_WIDTH = 4; const MAX_MENU_LABEL_LENGTH = MENU_BOX_WIDTH - ICON_ROW_PREFIX_WIDTH; +const menuFor = (view: 'default' | 'more-info', posthogSdkDetected: boolean) => + introMenuOptions({ view, showContinue: true, posthogSdkDetected }) ?? []; + +// Both detection states: only one offers the spell book and hedged Continue. const EVERY_LABEL = [ - ...CONTINUE_MENU_OPTIONS.map((o) => o.label), - ...sharingOptions(true).map((o) => o.label), -]; + ...menuFor('default', false), + ...menuFor('default', true), + ...menuFor('more-info', true), + ...sharingOptions(true), +].map((o) => o.label); describe('PostHogIntegrationIntroScreen menu labels', () => { - it.each(EVERY_LABEL.filter(Boolean))( + it.each([...new Set(EVERY_LABEL.filter(Boolean))])( '"%s" fits the intro menu column', (label) => { expect(label.length).toBeLessThanOrEqual(MAX_MENU_LABEL_LENGTH); @@ -33,13 +37,15 @@ describe('PostHogIntegrationIntroScreen menu labels', () => { it('leaves the disclosure row to the layout', () => { // IntroScreenLayout appends it to every intro menu — see its own test. // A copy here would drift, which is how the panel got five names. - expect(CONTINUE_MENU_OPTIONS.map((o) => o.value)).not.toContain('privacy'); + for (const view of ['default', 'more-info'] as const) { + expect(menuFor(view, true).map((o) => o.value)).not.toContain('privacy'); + } }); it('does not ask the user to decide about sharing to continue', () => { // The choice lives in the panel. A decline option next to Continue makes // the first decision be about data rather than about the wizard. - const values = CONTINUE_MENU_OPTIONS.map((o) => o.value); + const values = menuFor('default', false).map((o) => o.value); expect(values).not.toContain('continue-no-scan'); }); }); diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index b8507ca0d..b548d37ee 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -303,6 +303,7 @@ export class WizardStore { setFrameworkContext: (k, v) => this.setFrameworkContext(k, v), setFrameworkConfig: (i, c) => this.setFrameworkConfig(i, c), setDetectedFramework: (l) => this.setDetectedFramework(l), + setPosthogSdkDetected: (d) => this.setPosthogSdkDetected(d), setSkillId: (id) => this.setSkillId(id), setUnsupportedVersion: (info) => this.setUnsupportedVersion(info), addDiscoveredFeature: (f) => this.addDiscoveredFeature(f), @@ -533,6 +534,11 @@ export class WizardStore { this.emitChange(); } + setPosthogSdkDetected(detected: boolean): void { + this.$session.setKey('posthogSdkDetected', detected); + this.emitChange(); + } + setSkillId(skillId: string | null): void { this.$session.setKey('skillId', skillId); this.emitChange(); @@ -936,6 +942,26 @@ export class WizardStore { this.emitChange(); } + switchProgram(program: ProgramId): void { + if (program === this.router.activeProgram) return; + + // Flush unresolved promises so the wizard can advance + for (const gate of this._gates.values()) gate.resolve(); + this._gates.clear(); + + this.router.setProgram(program); + this._initFromProgram(program); + // start-tui stamps this once at launch; without it here every event + // after the switch still reports under the program the run started as. + analytics.setTag('program_id', program); + + const config = getProgramConfig(program); + this.$session.setKey('setupConfirmed', false); + this.$session.setKey('programLabel', config.id); + this.$session.setKey('skillId', config.skillId ?? null); + this.emitChange(); + } + // ── Derived state ─────────────────────────────────────────────── /** diff --git a/src/utils/__tests__/analytics.test.ts b/src/utils/__tests__/analytics.test.ts index fe4ed0046..347424ec1 100644 --- a/src/utils/__tests__/analytics.test.ts +++ b/src/utils/__tests__/analytics.test.ts @@ -638,6 +638,33 @@ describe('Analytics', () => { }); }); + describe('sessionProperties', () => { + it('includes the posthog_sdk_detected verdict once sharing is granted', () => { + const session = buildSession({}); + session.scanConsent = ScanConsent.Granted; + expect(sessionProperties(session).posthog_sdk_detected).toBe(false); + + session.posthogSdkDetected = true; + expect(sessionProperties(session).posthog_sdk_detected).toBe(true); + }); + + // It is a package.json scan result, so it waits on the same consent. + it('omits the verdict while consent is undecided or declined', () => { + const session = buildSession({}); + session.posthogSdkDetected = true; + + expect(session.scanConsent).toBe(ScanConsent.Undecided); + expect(sessionProperties(session)).not.toHaveProperty( + 'posthog_sdk_detected', + ); + + session.scanConsent = ScanConsent.Declined; + expect(sessionProperties(session)).not.toHaveProperty( + 'posthog_sdk_detected', + ); + }); + }); + describe('groupsFromUser', () => { const userWith = (overrides: Partial): ApiUser => ({ diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 5ceafd8aa..d6500a015 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -7,6 +7,7 @@ import { } from '@lib/constants'; import { reportableDiscoveredFeatures, + reportablePosthogSdkDetected, type WizardSession, } from '@lib/wizard-session'; import type { ApiUser } from '@lib/api'; @@ -46,6 +47,7 @@ export function sessionProperties( // might be absent. An absent key is unambiguous, while an empty array // would read as "we looked and found nothing" instead of "not reported". const discoveredFeatures = reportableDiscoveredFeatures(session); + const posthogSdkDetected = reportablePosthogSdkDetected(session); return { integration: session.integration, @@ -58,6 +60,9 @@ export function sessionProperties( scan_consent: session.scanConsent, additional_features: session.additionalFeatureQueue, run_phase: session.runPhase, + ...(posthogSdkDetected !== undefined + ? { posthog_sdk_detected: posthogSdkDetected } + : {}), }; }